作成してきたメモアプリにグローバル状態管理を導入します。
これまでの学習内容については以下の記事にまとめています。
グローバルな状態管理の利点
それぞれのコンポーネントは状態という「データ」を持つことができます。
しかし、コンポーネントが複雑になるにつれて状態管理も複雑になります。
特に、propsの「バケツリレー」はコードの可読性とメンテナンス性を低下させます。
![](https://inakademac.com/wp-content/uploads/2024/06/スクリーンショット-2024-06-17-20.11.02-1024x887.png)
この課題を解決するために登場したのがグローバルな状態管理です。
クラウドのように一元管理することでコンポーネントが直接データを取得できます。
これによりpropsを何層も深く渡す必要がなくなり、可読性とメンテナンス性が向上します。
![](https://inakademac.com/wp-content/uploads/2024/06/スクリーンショット-2024-06-18-7.20.01-1024x958.png)
Reactの状態管理を行う代表的なものとしてReduxとContext APIが存在します。
この記事ではこちらの記事を参考に、アプリケーションの規模が小さい場合にオススメのContext APIを使用します。
Context APIの使い方
では、作成中のメモアプリに対してContext APIを導入していきます。
メモアプリの作成過程についてはこちらにまとめてあります。
Context APIはReactに標準で含まれているので別途インストールする必要はありません。
Context APIを使用するために行う変更は以下の3つです。
- ContextProviderコンポーネントを作成する
- Contextを使用する範囲をContextProviderで囲う
- useContextでContextを取得する
![](https://inakademac.com/wp-content/uploads/2024/06/2024-06-19-7.08.35-1024x556.jpg)
ContextProviderコンポーネントを作成する
ContextProviderコンポーネントは、propsで渡していたデータや関数を定義して提供します。
元々MemoContainerにあったstateや関数を引っ越しします。
まず、src/context/MemoContext.tsx
ファイルを作成し、以下のコードを追加します。
import { ReactNode, createContext, useContext, useEffect, useRef, useState } from "react";
import { Memo } from "../Types";
// Contextオブジェクトの型
interface MemoContextProps {
originalMemos: Memo[];
currentMemos: Memo[];
newMemo: Memo;
setNewMemo: React.Dispatch<React.SetStateAction<Memo>>;
textareaRefs: React.MutableRefObject<HTMLTextAreaElement[]>;
fetchMemos: () => void;
handleMemoUpdate: (targetMemo: Memo) => void;
handleMemoDelete: (id: number) => void;
handleMemoEdit: (e: React.ChangeEvent<HTMLTextAreaElement>, id: number) => void;
handleRowClick: (tableId: number) => void;
handleKeyDown: (e: React.KeyboardEvent, targetMemo: Memo, tableId: number) => void;
}
const MemoContext = createContext<MemoContextProps | undefined>(undefined);
const MemoProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [originalMemos, setOriginalMemos] = useState<Memo[]>([]); // データベースから取得したメモの一覧
const [currentMemos, setCurrentMemos] = useState<Memo[]>([]); // フロントエンドで変更したメモの一覧
const initialMemo: Memo = { id: -1, content: "", createdAt: "", updatedAt: "" };
const [newMemo, setNewMemo] = useState<Memo>(initialMemo); // 新規メモ
const textareaRefs = useRef<HTMLTextAreaElement[]>([]); // 対象のテキストエリアにfocusするために使用
useEffect(() => {
fetchAndSetMemos();
}, []);
// GET:メモ一覧を取得
const fetchMemos = async () => {
try {
const response = await fetch(`${process.env.REACT_APP_API_BASE_URL}/api/memos`);
if (!response.ok) throw new Error("Failed to fetch memos");
const memos = await response.json();
return memos;
} catch (error) {
console.error("Error fetching memos:", error);
}
};
const fetchAndSetMemos = async () => {
const memos = await fetchMemos();
setOriginalMemos(memos);
setCurrentMemos(JSON.parse(JSON.stringify(memos)));
};
// PUT・POST:メモを更新・作成
const handleMemoUpdate = async (targetMemo: Memo) => {
if (targetMemo?.content.trim() === "") {
setCurrentMemos(originalMemos);
return;
}
const url = targetMemo.createdAt
? `${process.env.REACT_APP_API_BASE_URL}/api/memos/${targetMemo.id}` // PUT
: `${process.env.REACT_APP_API_BASE_URL}/api/memos`; // POST
const method = targetMemo.createdAt ? "PUT" : "POST";
try {
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: targetMemo.content,
});
if (!response.ok)
throw new Error(`Failed to ${method === "PUT" ? "update" : "add"} memo`);
setNewMemo(initialMemo);
fetchAndSetMemos();
} catch (error) {
console.error(`Error ${method === "PUT" ? "updating" : "adding"} memo:`, error);
}
};
// DELETE:メモを削除
const handleMemoDelete = async (id: number) => {
try {
const response = await fetch(
`${process.env.REACT_APP_API_BASE_URL}/api/memos/${id}`,
{
method: "DELETE",
}
);
if (!response.ok) throw new Error(`Failed to delete memos id: ${id}`);
fetchAndSetMemos();
} catch (error) {
console.error("Error deleting memo:", error);
}
};
const handleMemoEdit = (e: React.ChangeEvent<HTMLTextAreaElement>, id: number) => {
// 編集中の内容を更新
setCurrentMemos((prevMemos) =>
prevMemos.map((memo) =>
memo.id === id ? { ...memo, content: e.target.value } : memo
)
);
};
// 列のどこかをクリックしたら、対象のテキストエリアにfocusする
const handleRowClick = (tableId: number) => {
const textarea = textareaRefs.current[tableId];
if (textarea) {
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
}
};
// キーを押したときの処理
const handleKeyDown = (e: React.KeyboardEvent, targetMemo: Memo, tableId: number) => {
if (e.key === "Enter" && !e.shiftKey && e.keyCode !== 229) {
// 変換確定(229)を除くEnterキー押下時の処理
e.preventDefault();
handleMemoUpdate(targetMemo);
if (tableId < currentMemos.length) {
handleRowClick(tableId + 1); // 次のメモに移動(あれば)
}
} else if (e.key === "Escape") {
// escキーが押された場合の処理
const textarea = textareaRefs.current[tableId];
if (textarea) textarea.blur();
}
// Enterのデフォルト(改行)をキャンセルしているので自動的にshift+Enterで改行される
};
return (
<MemoContext.Provider
value={{
originalMemos,
currentMemos,
newMemo,
setNewMemo,
textareaRefs,
fetchMemos,
handleMemoUpdate,
handleMemoDelete,
handleMemoEdit,
handleRowClick,
handleKeyDown,
}}
>
{children}
</MemoContext.Provider>
);
};
// useMemoContextフックを追加
const useMemoContext = () => {
const context = useContext(MemoContext);
if (!context) {
throw new Error("useMemoContext must be used within a MemoProvider");
}
return context;
};
export {MemoProvider, useMemoContext};
【useMemoContext】
useContextをカプセル化することで、コンテキストの詳細な実装を隠蔽し、コンポーネント間での状態管理がより一貫して安全に行えます。
このアプローチにより、コンテキストの利用者はuseMemoContextフックを介してのみコンテキストの値や関数にアクセスでき、直接MemoContextオブジェクトに依存することを防ぎます。これにより、アプリケーションの構造がよりクリーンで保守しやすくなります。
Contextを使用する範囲をContextProviderで囲う
次に、MemoProvider
でアプリケーション全体をラップします。
これにより、MemoProvider
内で定義されたContextがアプリ全体で利用可能になります。
import React from "react";
import "./App.css";
import MemoContainer from "./components/MemoContainer/MemoContainer";
import { MemoProvider } from "./context/MemoContext";
const App: React.VFC = () => {
return (
<div className="App">
<h1>Simple Memo</h1>
<MemoProvider>
<MemoContainer />
</MemoProvider>
</div>
);
};
export default App;
useContextでContextを取得する
コンポーネントでContextの値を使用するには、useMemoContext
フックを作成し、それを使用します。
currentMemos(フロントエンドで変更したメモの一覧)のみ取得します。
import React from "react";
import "../../App.css";
import MemoRow from "./MemoRow";
import { useMemoContext } from "../../context/MemoContext";
const MemoTable: React.VFC = () => {
const {currentMemos} = useMemoContext();
return (
<>
{currentMemos.map((memo, tableId) => (
<MemoRow
memo={memo}
tableId={tableId}
key={memo.id}
/>
))}
</>
);
};
export default MemoTable;
メモの更新や削除、編集に必要な関数をContextから取得します。
親コンポーネントのMemoTableからループの要素であるmemoとtableIdをpropsで受け取ります。
アプリケーション全体で共有しているものか、propsで親コンポーネント関連のものか分かりやすいです。
import React from "react";
import { MemoRowProps } from "../../Types";
import "../../App.css";
import DeleteIcon from "@mui/icons-material/Delete";
import { useMemoContext } from "../../context/MemoContext";
const MemoRow: React.VFC<MemoRowProps> = ({
memo,
tableId,
}) => {
const {
handleMemoUpdate,
handleMemoDelete,
handleMemoEdit,
handleRowClick,
handleKeyDown,
textareaRefs,
} = useMemoContext();
return (
<tr className="memo__row" key={memo.id} onClick={() => handleRowClick(tableId)}>
<td>
<textarea
className="memo__textarea"
value={memo.content}
onChange={(e) => {
handleMemoEdit(e, memo.id);
}}
onBlur={() => handleMemoUpdate(memo)}
ref={(textareaDOM) => {
if (textareaDOM) {
textareaRefs.current[tableId] = textareaDOM;
}
}}
onKeyDown={(e) => handleKeyDown(e, memo, tableId)}
/>
</td>
<td className="memo__updatedAt">{memo.updatedAt}</td>
<td>
<button className="memo__delete" onClick={() => handleMemoDelete(memo.id)}>
<DeleteIcon />
</button>
</td>
</tr>
);
};
export default MemoRow;
メモの新規追加に必要な関数をContextから取得します。
import React from "react";
import "../../App.css";
import { useMemoContext } from "../../context/MemoContext";
const NewMemoRow: React.VFC = () => {
const {
currentMemos,
newMemo,
setNewMemo,
handleMemoUpdate,
handleRowClick,
handleKeyDown,
textareaRefs,
} = useMemoContext();
return (
<tr
className="memo__row"
onClick={() => handleRowClick(currentMemos.length)}
data-testid="new-memo-row"
>
<td>
<textarea
className="memo__textarea"
value={newMemo.content}
placeholder="Create Your Memo"
onChange={(e) =>
setNewMemo({
id: -1,
content: e.target.value,
createdAt: "",
updatedAt: "",
})
}
onBlur={() => handleMemoUpdate(newMemo!)}
ref={(textareaDOM) => {
if (textareaDOM) {
textareaRefs.current[currentMemos.length] = textareaDOM;
}
}}
onKeyDown={(e) => handleKeyDown(e, newMemo, currentMemos.length)}
data-testid="new-memo-textarea"
/>
</td>
<td className="memo__updatedAt"></td>
<td>
<button className="memo__delete"></button>
</td>
</tr>
);
};
export default NewMemoRow;
また、Contextを取得するにあたって、propsで受け取るデータが減ります。
TypeScriptを使用している場合、Propsの型もコンパクトにすることができます。
export interface Memo {
id: number;
content: string;
createdAt: string;
updatedAt: string;
}
// interface MemoTableProps 不要なので削除
// interface NewMemoRowProps 不要なので削除
export interface MemoRowProps {
memo: Memo;
tableId: number;
}
まとめ
今回はグローバルな状態管理をするためのContext APIを導入しました。
propsのバケツリレーを解消できるほか、アプリケーション全体で共有しているものか、propsで親コンポーネント関連のものか分かりやすく感じました。
Contextで取得するものとpropsで取得するものを使い分けることで読みやすいコードになりそうです。
参考文献
![](https://qiita-user-contents.imgix.net/https%3A%2F%2Fcdn.qiita.com%2Fassets%2Fpublic%2Fengineer-festa-ogp-background-074608b13b4bbe67c10ada41e7e2d292.png?ixlib=rb-4.0.0&w=1200&mark64=aHR0cHM6Ly9xaWl0YS11c2VyLWNvbnRlbnRzLmltZ2l4Lm5ldC9-dGV4dD9peGxpYj1yYi00LjAuMCZ3PTk3MiZoPTM3OCZ0eHQ9UmVhY3QlMjBDb250ZXh0JTIwQVBJJUUzJTgxJUFFJUU0JUJEJUJGJUUzJTgxJTg0JUU2JTk2JUI5JnR4dC1hbGlnbj1sZWZ0JTJDdG9wJnR4dC1jb2xvcj0lMjNGRkZGRkYmdHh0LWZvbnQ9SGlyYWdpbm8lMjBTYW5zJTIwVzYmdHh0LXNpemU9NTYmcz0zOTA3NTljYTgzYzE5MDE1NWYwNDEyMjFjZjkxMmY2Mw&mark-x=120&mark-y=96&blend64=aHR0cHM6Ly9xaWl0YS11c2VyLWNvbnRlbnRzLmltZ2l4Lm5ldC9-dGV4dD9peGxpYj1yYi00LjAuMCZoPTc2Jnc9OTcyJnR4dD0lNDBzdHVkaW9faGFuZXlhJnR4dC1jb2xvcj0lMjNGRkZGRkYmdHh0LWZvbnQ9SGlyYWdpbm8lMjBTYW5zJTIwVzYmdHh0LXNpemU9MzYmdHh0LWFsaWduPWxlZnQlMkN0b3Amcz04ZDMwMWNjMzMxZmIxMzBlMGRjMWU2YTI0OTU4YjllMA&blend-x=120&blend-y=500&blend-mode=normal&s=e4c36bf4f828686d46e2260dc6564877)
![](https://qiita-user-contents.imgix.net/https%3A%2F%2Fcdn.qiita.com%2Fassets%2Fpublic%2Farticle-ogp-background-412672c5f0600ab9a64263b751f1bc81.png?ixlib=rb-4.0.0&w=1200&mark64=aHR0cHM6Ly9xaWl0YS11c2VyLWNvbnRlbnRzLmltZ2l4Lm5ldC9-dGV4dD9peGxpYj1yYi00LjAuMCZ3PTk3MiZoPTM3OCZ0eHQ9UmVhY3QlMjBDb250ZXh0JTIwQVBJJUUzJTgxJUFFJUU2JUE2JTgyJUU1JUJGJUI1JUUzJTgxJUE4JUU0JUJEJUJGJUUzJTgxJTg0JUU2JTk2JUI5JnR4dC1hbGlnbj1sZWZ0JTJDdG9wJnR4dC1jb2xvcj0lMjMyMTIxMjEmdHh0LWZvbnQ9SGlyYWdpbm8lMjBTYW5zJTIwVzYmdHh0LXNpemU9NTYmcz00ZjgzMmJlM2M0ZDVmZTZmOTRmNTBjOTljYzYxMjZlMA&mark-x=142&mark-y=57&blend64=aHR0cHM6Ly9xaWl0YS11c2VyLWNvbnRlbnRzLmltZ2l4Lm5ldC9-dGV4dD9peGxpYj1yYi00LjAuMCZoPTc2Jnc9NzcwJnR4dD0lNDBoaW5ha29fbiZ0eHQtY29sb3I9JTIzMjEyMTIxJnR4dC1mb250PUhpcmFnaW5vJTIwU2FucyUyMFc2JnR4dC1zaXplPTM2JnR4dC1hbGlnbj1sZWZ0JTJDdG9wJnM9YWM2NGUxNDk1YWRlMzZiMmY4OWFkMWM3ZWFiMzdlNDg&blend-x=142&blend-y=486&blend-mode=normal&s=73a7993ba9623817f2e823bf90b06f4b)
コメント