こんにちは、かつコーチです。
前回は useReducer で複雑な状態管理を整理する方法を解説しました。
今回は、その useReducer とセットでよく使われる「Context API」を使って、複数のコンポーネントから参照できるグローバルな状態を作っていきます。
「ログイン中のユーザー情報」や「テーマ(ダーク/ライトモード)」のように、アプリ全体で使い回したい状態を管理するときに欠かせない機能なので、実践的なコード例と一緒に理解していきましょう。
Context APIとは?
props drilling問題を解決する仕組み
Reactでは通常、親コンポーネントから子コンポーネントへ props(コンポーネントに渡すデータ)を使ってデータを渡します。
しかし、深い階層のコンポーネントにデータを渡したい場合、途中のコンポーネントすべてにpropsを中継させる必要が出てきます。
これをprops drilling(プロップスの穴掘り)と呼び、中継するだけのコンポーネントが増えてコードの見通しが悪くなる原因になります。
// props drillingの例:Appで作ったuserを何段も渡していく
function App() {
const user = { name: "かつコーチ" };
return <Header user={user} />;
}
function Header({ user }: { user: { name: string } }) {
return <UserMenu user={user} />; // 自分では使わないのに中継するだけ
}
function UserMenu({ user }: { user: { name: string } }) {
return <p>{user.name}さん</p>;
}
Header コンポーネントは user を自分では使わず、ただ UserMenu に渡すためだけに受け取っています。
Context API を使うと、こうした中継を挟まずに、必要なコンポーネントが直接データを取得できるようになります。
createContextとProvider・Consumerの関係
Context APIには3つの登場人物がいます。
createContext:Contextの「箱」を作るProvider:Contextに値を流し込む(配る側)useContext:Providerが配った値を受け取る(受け取る側)
イメージとしては、Provider が配ったデータを、その内側にあるどのコンポーネントからでも useContext で取り出せる、という仕組みです。
基本の書き方
Contextを作成する
まずはユーザー情報を管理するContextを作ってみましょう。
import { createContext, useContext, useState, ReactNode } from "react";
type User = {
name: string;
email: string;
};
type UserContextValue = {
user: User | null;
login: (user: User) => void;
logout: () => void;
};
const UserContext = createContext<UserContextValue | null>(null);
createContext の型引数には、Contextで扱う値の型を指定します。
初期値を null にしているのは、「Providerの外側で使われたとき」を区別できるようにするためです。
Providerコンポーネントを作る
次に、Contextの値を配るProviderコンポーネントを作ります。
function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = (newUser: User) => setUser(newUser);
const logout = () => setUser(null);
return (
<UserContext.Provider value={{ user, login, logout }}>
{children}
</UserContext.Provider>
);
}
children を受け取って、そのまま Provider の中に描画しているのがポイントです。
こうすることで、UserProvider で囲んだ範囲の中にあるどのコンポーネントからも、後述の useContext でユーザー情報にアクセスできるようになります。
カスタムフックでuseContextをラップする
Contextを直接 useContext(UserContext) として使うこともできますが、実践では専用のカスタムフックを用意するのが定番です。
function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error("useUserはUserProviderの内側で使ってください");
}
return context;
}
null チェックをこのフック内にまとめておくことで、利用する側のコンポーネントでは null の可能性を気にせずに使えるようになります(TypeScriptの型も自動的に絞り込まれます)。
実際に使ってみる
用意したProviderとカスタムフックを使ってみましょう。
function App() {
return (
<UserProvider>
<LoginButton />
<UserMenu />
</UserProvider>
);
}
function LoginButton() {
const { user, login } = useUser();
if (user) return null;
return (
<button onClick={() => login({ name: "かつコーチ", email: "katsu@example.com" })}>
ログイン
</button>
);
}
function UserMenu() {
const { user, logout } = useUser();
if (!user) return <p>未ログインです</p>;
return (
<div>
<p>{user.name}さん、こんにちは</p>
<button onClick={logout}>ログアウト</button>
</div>
);
}
LoginButton と UserMenu は親子関係にありませんが、どちらも UserProvider の内側にあるため、同じユーザー情報を共有できています。
props drillingを一切せずに、必要なコンポーネントが直接状態にアクセスできているのが分かると思います。
つまずきやすいポイント:不要な再レンダリング
かつコーチが実際にハマった話
私が実務でContext APIを使い始めたころ、Contextの値を更新するたびに、Contextを使っているすべてのコンポーネントが再レンダリングされてしまい、画面がカクつく問題にぶつかったことがあります。
原因は、Providerに渡す value の中身を、レンダリングのたびに新しいオブジェクトとして作り直していたことでした。
❌ Before:valueを毎回新しいオブジェクトとして作る
function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
return (
// レンダリングのたびに { user, login, logout } が新しいオブジェクトになる
<UserContext.Provider
value={{
user,
login: (newUser: User) => setUser(newUser),
logout: () => setUser(null),
}}
>
{children}
</UserContext.Provider>
);
}
value に渡しているオブジェクトや関数を毎回その場で作っているため、UserProvider が再レンダリングされるたびに value の参照が変わってしまいます。
その結果、useContext を使っているすべての子コンポーネントが、値の中身が実質同じでも再レンダリングされてしまいます。
✅ After:useMemoとuseCallbackで参照を安定させる
import { createContext, useContext, useState, useMemo, useCallback, ReactNode } from "react";
function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = useCallback((newUser: User) => setUser(newUser), []);
const logout = useCallback(() => setUser(null), []);
const value = useMemo<UserContextValue>(
() => ({ user, login, logout }),
[user, login, logout]
); return <UserContext.Provider value={value}>{children}</UserContext.Provider>; }
useCallback で関数の参照を固定し、useMemo でオブジェクト全体の参照も依存配列が変わらない限り再利用するようにしました。
こうすることで、user の値が実際に変わったときだけ、Contextを使っているコンポーネントが再レンダリングされるようになります。
規模の大きいアプリほど効いてくる最適化なので、Context APIを使うときはセットで覚えておくとよいでしょう。
応用・一歩先の使い方
複数のContextを使い分ける
1つのContextにすべての状態を詰め込むと、どこかの値が変わるたびに関係ないコンポーネントまで再レンダリングされやすくなります。
「ユーザー情報用」「テーマ用」「通知用」のように、目的ごとにContextを分けるのが実践的な設計です。
const UserContext = createContext<UserContextValue | null>(null);
const ThemeContext = createContext<"light" | "dark">("light");
更新頻度や関心事が異なる状態は、別々のContextに分離しておくと、パフォーマンスの面でも見通しの面でも扱いやすくなります。
useReducerと組み合わせて更新ロジックを整理する
前回解説した useReducer とContext APIを組み合わせると、状態が複雑な場合でも更新ロジックを1箇所にまとめつつ、グローバルに公開できます。
const TodoStateContext = createContext<TodoState | null>(null);
const TodoDispatchContext = createContext<React.Dispatch<TodoAction> | null>(null);
function TodoProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(todoReducer, { todos: [] });
return (
<TodoStateContext.Provider value={state}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
);
}
状態用と更新関数用のContextを分けることで、「値だけ読みたいコンポーネント」と「更新だけしたいコンポーネント」を分離でき、再レンダリングの範囲もより細かくコントロールできます。
このパターンは、次回解説するRedux Toolkitの考え方にも通じる部分があります。
まとめ
この記事のポイント
- Context APIは、props drillingを避けてコンポーネント間でデータを共有する仕組み
createContext→Provider→useContextの3ステップで使うuseContextは専用のカスタムフックでラップし、nullチェックをまとめておく- Providerの
valueはuseMemo/useCallbackで参照を安定させ、不要な再レンダリングを防ぐ - 関心事ごとにContextを分割し、
useReducerと組み合わせると複雑な状態も整理しやすい
次に読むべき記事
次回は、状態管理ライブラリの定番であるRedux Toolkitの入門編を解説します。
→ 次の記事:Redux Toolkit入門:状態管理ライブラリの定番