第5回

useContextでグローバル状態管理:props drilling を解消する

ReactのuseContextを解説します。props drillingの問題・createContext・Provider・useContextで値を取得する方法、TypeScriptでの型安全な実装まで実例付きで学べます。

·13分で読める
たける
たける ログイン中のユーザー情報を App → Layout → Sidebar → UserAvatar と4階層渡してて、途中のコンポーネントは使いもしないのにpropsに書かないといけなくて……。
りこ
りこ props drillingの典型例。useContextで解消できる。ContextはReactのツリー内ならどこからでも値を取り出せる「グローバルな棚」。

props drillingの問題

コンポーネントが深くネストされると、途中のコンポーネントが使わないpropsを中継するだけの「props drilling」が起きます。

App(user を持つ)
└── Layout(user を使わないが渡す)
    └── Sidebar(user を使わないが渡す)
        └── UserAvatar(user を表示する)
// Layoutは user を使わないが渡すだけ
function Layout({ user }: { user: User }) {
  return <Sidebar user={user} />;
}

// Sidebarも同様
function Sidebar({ user }: { user: User }) {
  return <UserAvatar user={user} />;
}

この問題を解消するのが useContext です。

Contextの作り方

1. createContextでContextを作成

import { createContext } from 'react';

type User = {
  id: number;
  name: string;
  avatarUrl: string;
};

type UserContextType = {
  user: User | null;
  setUser: (user: User | null) => void;
};

// Contextを作成(初期値を渡す)
export const UserContext = createContext<UserContextType>({
  user: null,
  setUser: () => {},
});

2. ProviderでContextの値を提供

import { useState } from 'react';
import { UserContext } from './user-context';

function App() {
  const [user, setUser] = useState<User | null>(null);

  return (
    // Provider で囲んだコンポーネントツリー全体で値が使える
    <UserContext.Provider value={{ user, setUser }}>
      <Layout />
    </UserContext.Provider>
  );
}

3. useContextで値を取得

import { useContext } from 'react';
import { UserContext } from './user-context';

// どこからでも、propsを経由せずに取得できる
function UserAvatar() {
  const { user } = useContext(UserContext);

  if (!user) return <div>未ログイン</div>;

  return (
    <div>
      <img src={user.avatarUrl} alt={user.name} />
      <span>{user.name}</span>
    </div>
  );
}

カスタムHookでContextをラップする

useContext を直接呼ぶより、カスタムHookでラップするとコードが整理されます。

// user-context.tsx
import { createContext, useContext, useState } from 'react';
import type { ReactNode } from 'react';

type User = { id: number; name: string };

type UserContextType = {
  user: User | null;
  login: (user: User) => void;
  logout: () => void;
};

const UserContext = createContext<UserContextType | null>(null);

// Provider コンポーネント
export function UserProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  const login = (user: User) => setUser(user);
  const logout = () => setUser(null);

  return (
    <UserContext.Provider value={{ user, login, logout }}>
      {children}
    </UserContext.Provider>
  );
}

// カスタムHook(型安全にContextを取得)
export function useUser() {
  const context = useContext(UserContext);
  if (!context) {
    throw new Error('useUser must be used within UserProvider');
  }
  return context;
}
// 使い方
function App() {
  return (
    <UserProvider>
      <Header />
      <Main />
    </UserProvider>
  );
}

function Header() {
  const { user, logout } = useUser();

  return (
    <header>
      {user ? (
        <>
          <span>{user.name}</span>
          <button onClick={logout}>ログアウト</button>
        </>
      ) : (
        <a href="/login">ログイン</a>
      )}
    </header>
  );
}

テーマ切り替えの実装例

実務でよく使われるダークモード切り替えです。

// theme-context.tsx
import { createContext, useContext, useState } from 'react';
import type { ReactNode } from 'react';

type Theme = 'light' | 'dark';

type ThemeContextType = {
  theme: Theme;
  toggleTheme: () => void;
};

const ThemeContext = createContext<ThemeContextType | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');

  const toggleTheme = () => {
    setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      <div data-theme={theme}>
        {children}
      </div>
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be used within ThemeProvider');
  return context;
}

Contextのパフォーマンスに注意

Contextの値が変わると、useContext を呼んでいるすべてのコンポーネントが再レンダリングされます。

// ❌ 変わりやすい値と変わりにくい値を1つのContextにまとめると無駄な再レンダリングが起きる
const AppContext = createContext({ user, theme, notifications, ... });

// ✅ 関心ごとにContextを分ける
const UserContext = createContext({ user, login, logout });
const ThemeContext = createContext({ theme, toggleTheme });

頻繁に変わる値(例えばマウス座標)はContextには向きません。局所的なstateか状態管理ライブラリを検討します。

たける
たける 便利だからと言ってなんでもContextに入れていいんですか? テーマ・ユーザー情報・検索クエリ・カートの中身……。
りこ
りこ 変化頻度で判断する。テーマやログイン情報のように「たまにしか変わらない」ものは向いている。カート内容や入力中のフォームのように「頻繁に変わる」ものをContextに入れると、購読している全コンポーネントが再レンダリングされて重くなる。
ユナ
ユナ バックエンドで言うとContextはグローバル変数に近い。グローバル変数は便利だけど何でも突っ込むとデバッグが辛くなる。「どこで変わったか追いにくくなったら」が見直しのサイン。

まとめ

  • props drilling(中間コンポーネントへの不要なpropsの受け渡し)を useContext で解消する
  • createContextProvider でツリーに値を提供 → useContext で取得
  • カスタムHookでラップすると型安全かつ使いやすくなる
  • Contextの値が変わるとすべての購読コンポーネントが再レンダリングされる点に注意

次の第6回では、複雑な状態管理に使う useReducer を学びます。