React でアプリを作っていると、「アプリ全体で使いたいテーマの色」や「ログイン中のユーザー情報」など、あちこちのコンポーネントで共有したい値が出てきます。これを props だけで渡そうとすると、途中のコンポーネントが自分では使わない値をひたすら受け渡す、いわゆるバケツリレー(prop drilling)が発生します。そこで役立つのが Context API と useContext フックです。この記事では、Context を作って値を渡し、離れた子コンポーネントで受け取るまでの流れを、テーマ切り替えやユーザー情報の共有といった具体例で解説します。
目次
props のバケツリレー(prop drilling)とは
React では、親から子へデータを渡す基本の手段は props です。ところが、深くネストしたコンポーネントに値を届けたいとき、間にあるコンポーネントがその値を使わないのに props として中継しなければならないことがあります。これが prop drilling です。次のコードでは、App が持つ user を、Layout や Header を経由して UserBadge まで手渡ししています。
// user を使うのは一番奥の UserBadge だけなのに、
// 途中の Layout と Header も props を中継している
function App() {
const user = { name: 'たくみ' };
return <Layout user={user} />;
}
function Layout({ user }) {
return <Header user={user} />; // 自分では使わないのに受け渡す
}
function Header({ user }) {
return <UserBadge user={user} />; // ここも中継するだけ
}
function UserBadge({ user }) {
return <span>こんにちは、{user.name} さん</span>;
}
階層が浅いうちは問題になりませんが、中継するコンポーネントが増えるほどコードが読みにくくなり、props の追加や変更も面倒になります。「離れた場所どうしで同じ値を共有したい」というときに、途中を飛ばして値を届ける仕組みが Context API です。
Context を作る・渡す・受け取るの3ステップ
Context を使う流れは、大きく分けて次の3ステップです。まず createContext で Context を作り、次に <Context.Provider> で値を渡し、最後に子コンポーネントで useContext を使って値を受け取ります。それぞれの登場人物の役割を先に整理しておきましょう。
| API・要素 | 役割 |
|---|---|
createContext(初期値) | 共有用の Context オブジェクトを作る。引数はデフォルト値 |
<Context.Provider value={...}> | 配下のコンポーネントに value の値を配る |
useContext(Context) | もっとも近い Provider の value を読み取るフック |
テーマ(ダーク/ライト)を共有する例
実際に、アプリ全体でテーマ(ダーク/ライト)を共有する例で3ステップを見ていきます。まずは createContext で Context を作ります。引数に渡すのはデフォルト値で、後述するように Provider の外で使われたときに返る値になります。
import { createContext } from 'react';
// 'light' がデフォルト値。型も 'light' | 'dark' に絞っておく
export const ThemeContext = createContext<'light' | 'dark'>('light');
次に、値を配りたい範囲を <ThemeContext.Provider> で囲み、value 属性に共有したい値を渡します。Provider で囲んだ内側であれば、どれだけ深い階層のコンポーネントでもこの値を受け取れます。
import { ThemeContext } from './ThemeContext';
function App() {
return (
// この中のどのコンポーネントも theme を 'dark' として受け取れる
<ThemeContext.Provider value="dark">
<Layout />
</ThemeContext.Provider>
);
}
// 途中の Layout は user のときのような中継が不要になる
function Layout() {
return <Toolbar />;
}
最後に、値を使いたいコンポーネントで useContext を呼びます。引数に受け取りたい Context オブジェクトを渡すと、もっとも近い Provider の value が返ってきます。Toolbar は App から props を一切受け取っていませんが、Context 経由で theme を読み取れています。
import { useContext } from 'react';
import { ThemeContext } from './ThemeContext';
function Toolbar() {
// 近くの Provider の value(ここでは 'dark')が入る
const theme = useContext(ThemeContext);
return (
<div className={theme === 'dark' ? 'toolbar-dark' : 'toolbar-light'}>
現在のテーマ: {theme}
</div>
);
}
これで、間の Layout を経由せずに App から Toolbar へ値が届きました。props のバケツリレーが消え、値を「使う側」と「配る側」だけがコードに現れるようになったのがポイントです。
デフォルト値と、Provider の外で使ったときの挙動
createContext の引数に渡したデフォルト値は、いつ使われるのか気になるところです。答えは、対応する Provider が上位に1つも存在しないまま useContext が呼ばれたときです。Provider で囲まれていれば、その value が優先され、デフォルト値は使われません。
const ThemeContext = createContext<'light' | 'dark'>('light');
function Standalone() {
// このコンポーネントを Provider で囲まずに描画すると…
const theme = useContext(ThemeContext);
return <p>{theme}</p>; // デフォルト値の 'light' が表示される
}
この挙動は、コンポーネント単体でのテストや、Provider を用意し忘れたときのフォールバックとして役立ちます。ただし、デフォルト値に頼りすぎると「Provider の付け忘れ」に気づきにくくなります。本来 Provider が必須のケースでは、デフォルト値を null や意味のない値にしておき、useContext を包んだ独自フックの中で「Provider がないときはエラーを投げる」ようにしておくと、付け忘れをすぐ検知できます。
値と更新関数を一緒に渡す(ユーザー情報の共有)
共有したいのは「読み取るだけの値」とは限りません。ログインユーザー情報のように、値そのものと、それを更新する関数の両方を共有したいことがよくあります。その場合は、Provider の内側で useState を持ち、値と更新関数をまとめてオブジェクトにして value に渡します。
import { createContext, useContext, useState, ReactNode } from 'react';
type User = { name: string } | null;
// 値(user)と更新関数(setUser)をセットで共有する型
type AuthValue = { user: User; setUser: (user: User) => void };
const AuthContext = createContext<AuthValue | null>(null);
// Provider は専用コンポーネントに切り出すと使いやすい
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User>(null);
return (
<AuthContext.Provider value={{ user, setUser }}>
{children}
</AuthContext.Provider>
);
}
// useContext を包んだ独自フック。Provider 外での使用を弾ける
export function useAuth() {
const value = useContext(AuthContext);
if (value === null) {
throw new Error('useAuth は AuthProvider の中で使ってください');
}
return value;
}
使う側は useAuth を呼ぶだけで、現在のユーザーと更新関数の両方を受け取れます。ログインボタンで setUser を呼べば、Provider の内側にある離れたコンポーネントの表示も一緒に切り替わります。
import { useAuth } from './AuthContext';
function LoginButton() {
const { user, setUser } = useAuth();
if (user) {
return <button onClick={() => setUser(null)}>ログアウト</button>;
}
return (
<button onClick={() => setUser({ name: 'たくみ' })}>
ログイン
</button>
);
}
このように、更新関数も一緒に渡すことで、Context は「読み取り専用の設定値」だけでなく「アプリ全体で共有する状態」も扱えるようになります。テーマの切り替えも、同じ要領で theme と setTheme をセットで渡せば実現できます。
使いすぎと再レンダリングの注意点
便利な Context ですが、注意したい点もあります。まず、value の値が変わると、その Context を useContext で読んでいるすべてのコンポーネントが再レンダリングされます。更新頻度の高い値を1つの大きな Context にまとめて入れてしまうと、関係のないコンポーネントまで頻繁に再描画され、パフォーマンスの低下につながります。頻繁に変わる値と滅多に変わらない値は、Context を分けて持つと影響範囲を狭められます。
もう一つ気をつけたいのが、value にオブジェクトをその場で作って渡すケースです。Provider が再レンダリングされるたびに value={{ user, setUser }} は毎回新しいオブジェクトになるため、値の中身が同じでも再レンダリングのきっかけになります。影響が気になる場合は useMemo でオブジェクトを固定するとよいでしょう。また、Context は「本当に離れた場所で共有したい値」に使うのが基本です。親から子へ一段渡すだけなら素直に props を使うほうが読みやすく、なんでも Context に入れるとかえって依存関係が見えにくくなります。
まとめ
Context API と useContext は、props のバケツリレーを解消して、離れたコンポーネント間で値を共有するための仕組みです。createContext で Context を作り、<Context.Provider value={...}> で値を配り、useContext で受け取る、という3ステップが基本の流れでした。テーマやログインユーザーのようにアプリ全体で使う値は Context 向きで、更新関数も一緒に渡せば共有状態として扱えます。一方で、value の変化がすべての読み取り側の再レンダリングを引き起こす点や、なんでも Context に入れると読みにくくなる点には注意し、props と使い分けていくのがコツです。