目次
20 章

Context とジェネリックコンポーネント

Context の型安全性パターン(null + ラッパーフック)、state · dispatch の分離、ジェネリックコンポーネント、そして多態コンポーネントの as prop まで一度に扱います。

19章まででコンポーネントの内側のもっともよくあるタイピングは整理されました。本章は一段階上で、複数のコンポーネントが共有する値(Context)と、複数の形のデータを受け取るコンポーネント(ジェネリック) を見ていきます。

12章(useContext)の JavaScript パターンを TypeScript の上に載せ直し、そこにジェネリックコンポーネントと多態コンポーネント(as prop)まで加えます。React 19 の ref-as-prop モデルとの関係も合わせて押さえます。

Context の型引数 — 初期値と使用時点が異なる #

createContext は初期値をそのまま受けてその型として推論します。問題は 初期値が意味ある値でない場合 が多いことです。「Provider の中でだけ意味があり、外で使ってはいけない値」をどう表現すればよいでしょうか。

3 つのよくあるパターンがあり、それぞれのトレードオフが異なります。

1) 意味ある既定値を与える方式 #

もっとも単純です。Provider なしでも動作する意味ある既定値を初期値として与えるものです。テーマのように「既定は light、必要なら Provider で override」のような場合に適しています。

既定値のある Context
import { createContext, useContext } from 'react';

type Theme = 'light' | 'dark';

const ThemeContext = createContext<Theme>('light');

function useTheme() {
  return useContext(ThemeContext);
}

// 使用箇所
function Toolbar() {
  const theme = useTheme();    // Theme — 常に意味ある値
  return <div className={theme}>...</div>;
}

長所は単純さです。短所は「Provider を忘れても動作はする」ためミスが遅れて検出されることです。

2) null 開始 + 安全な useContext ヘルパー #

Provider の中でだけ意味ある値(ユーザー情報、カート、dispatch など)のときは、初期値を null にし、使用箇所で一度検査するヘルパーを用意します。このパターンが実務でもっとも多く使われます。

null 開始 + ヘルパー
type User = { id: string; name: string };

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

export function useUser() {
  const user = useContext(UserContext);
  if (user === null) {
    throw new Error('useUser は UserProvider の中でのみ呼び出してください');
  }
  return user;     // ここから User にナローイングされる
}

// 使用箇所
function Profile() {
  const user = useUser();      // User(null 分岐不要)
  return <p>{user.name}</p>;
}

呼び出すコンポーネントが毎回 if (user === null) を書かなくて済むよう、ヘルパーが一度塞いでくれます。ヘルパーの中の throw が肝です。TypeScript がその後は null の可能性を除外してくれます。

本書はこのパターンを既定で使います。32章(認証とセッション)で useAuth を作るときも同じ形になります。

3) キャストで始める — 推奨しない #

createContext<User>({} as User) のような偽の初期値をキャストするパターンをよく見かけます。コードは短いですが、使用時点に Provider が無いと空のオブジェクトがそのまま漏れ出て ランタイムバグにつながります。ほぼ常にパターン 2 の方が安全です。

State と Dispatch を一緒に流すとき — 2 つの Context に分ける #

Context で状態と変更関数を一緒に下に流すとき、状態と dispatch を 2 つの Context に分けると再レンダリングのコストを減らせます。dispatch だけ使うコンポーネントが、状態が変わったからといって一緒に再レンダリングされるのを防げます。

state Context + dispatch Context
import { createContext, useContext, useReducer } from 'react';

type State = { count: number };
type Action = { type: 'inc' } | { type: 'dec' };

const StateContext = createContext<State | null>(null);
const DispatchContext = createContext<React.Dispatch<Action> | null>(null);

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'inc': return { count: state.count + 1 };
    case 'dec': return { count: state.count - 1 };
  }
}

export function CounterProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, { count: 0 });
  return (
    <StateContext.Provider value={state}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  );
}

export function useCounterState() {
  const v = useContext(StateContext);
  if (v === null) throw new Error('CounterProvider の中でのみ使用');
  return v;
}

export function useCounterDispatch() {
  const v = useContext(DispatchContext);
  if (v === null) throw new Error('CounterProvider の中でのみ使用');
  return v;
}

useCounterDispatch だけ使うコンポーネントは count が変わっても再レンダリングされません。小さなアプリでは過度な最適化かもしれませんが、Context で頻繁に変わる状態を流しているのであれば一度検討してみるパターンです。12章の value 分離パターンを TypeScript の上でより明確に実装した形です。

ジェネリックコンポーネント — どんなデータでも受け取るコンポーネント #

リスト、セレクト、テーブルのようなコンポーネントは「どんなデータでも受け取ってレンダリングして」が自然な要求です。関数にジェネリックを使うように、コンポーネントにもジェネリックを使えます。

ジェネリック List コンポーネント
type ListProps<T> = {
  items: readonly T[];
  renderItem: (item: T) => React.ReactNode;
  keyOf: (item: T) => string | number;
};

function List<T>({ items, renderItem, keyOf }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={keyOf(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// 使用箇所
type Todo = { id: string; text: string };

function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <List
      items={todos}
      keyOf={(t) => t.id}
      renderItem={(t) => <span>{t.text}</span>}
    />
  );
}

肝は function List<T>(props: ListProps<T>) のように function キーワードの後にジェネリックパラメータ を書くことです。呼び出し側では items の型から T が自動推論されます。

注記
.tsx ファイルでアロー関数 + ジェネリックは <T> が JSX タグとして解釈されてしまい曖昧になることがあります。そのためジェネリックコンポーネントはほぼ常に function 宣言の形を使います。

ジェネリックコンポーネント + 制約(extends) #

T がどんな形であるべきかという制約を付けると、コンポーネント本体でそのフィールドを直接使えます。

制約 — id フィールドを強制
type WithId = { id: string };

type ListProps<T extends WithId> = {
  items: readonly T[];
  renderItem: (item: T) => React.ReactNode;
};

function List<T extends WithId>({ items, renderItem }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{renderItem(item)}</li>   // item.id 使用可能
      ))}
    </ul>
  );
}

// 使用箇所は keyOf を書かなくてよい
<List items={todos} renderItem={(t) => <span>{t.text}</span>} />

この方式は呼び出しが短くなって良いですが、これで List が受け取るデータは必ず id: string を持つ制約ができます。両パターンとも使い所があり、柔軟性がより重要なら keyOf 関数を、安全性と短い呼び出しが重要なら制約 が適しています。

多態コンポーネント — as prop でタグを変える #

同じデザインですがある場所では <button>、ある場所では <a>、ある場所では <Link> としてレンダリングされるコンポーネントをよく作ります。これを 多態(polymorphic)コンポーネント と呼び、TypeScript で正しく捕まえるには少し手間がかかります。

17章で押さえた discriminated union の応用の一つです。

多態コンポーネント — 基礎形態
import type { ElementType, ComponentPropsWithoutRef } from 'react';

type BoxProps<E extends ElementType> = {
  as?: E;
  children?: React.ReactNode;
} & Omit<ComponentPropsWithoutRef<E>, 'as' | 'children'>;

function Box<E extends ElementType = 'div'>({
  as,
  children,
  ...rest
}: BoxProps<E>) {
  const Tag = as ?? 'div';
  return <Tag {...rest}>{children}</Tag>;
}

// 使用箇所
<Box>既定の div</Box>
<Box as="a" href="/about">リンクのように</Box>
<Box as="button" onClick={() => {}}>ボタンのように</Box>

動作原理を一行ずつほぐすと:

  1. E extends ElementType — E は HTML タグ名('div''a'、…)またはコンポーネント型
  2. ComponentPropsWithoutRef<E> — そのタグ・コンポーネントのすべての props を引いてくる
  3. Omit<..., 'as' | 'children'> — 自分たちが別途定義した prop と重ならないように除く
  4. デフォルトジェネリック E = 'div'<Box> だけ書いたとき型が div として捕まえられる

このパターン一つで、as="a" のときは href 自動補完、as="button" のときは onClick 自動補完がすべて正しく動作します。

多態コンポーネントは本当に必要なときだけ #

このパターンは強力ですが、型が急速に複雑になり、エディタの自動補完が重くなる可能性があります。デザインシステムライブラリの立場では価値が大きいですが、一般のアプリコードであれば as の代わりに Button / LinkButton の 2 つのコンポーネントを別々に作る方が読みやすい場合が多いです。 トレードオフを意識して使ってください。

ref を prop として受け取るジェネリックコンポーネント — React 19 #

18章で押さえた ref-as-prop モデルはジェネリックコンポーネントとも自然に組み合わさります。React 19 以前は forwardRef とジェネリックを一緒に使うのが厄介でしたが、今はそのまま prop として受け取れば済みます。

ジェネリック + ref-as-prop
import type { Ref } from 'react';

type SelectProps<T> = {
  ref?: Ref<HTMLSelectElement>;
  options: readonly T[];
  getValue: (item: T) => string;
  getLabel: (item: T) => string;
};

function Select<T>({ ref, options, getValue, getLabel }: SelectProps<T>) {
  return (
    <select ref={ref}>
      {options.map((opt) => (
        <option key={getValue(opt)} value={getValue(opt)}>
          {getLabel(opt)}
        </option>
      ))}
    </select>
  );
}

// 使用箇所
type Country = { code: string; name: string };
const countries: Country[] = [
  { code: 'kr', name: '大韓民国' },
  { code: 'us', name: 'United States' },
];

function CountryPicker() {
  const ref = useRef<HTMLSelectElement>(null);
  return (
    <Select
      ref={ref}
      options={countries}
      getValue={(c) => c.code}
      getLabel={(c) => c.name}
    />
  );
}

古い forwardRef とジェネリックを一緒に使うモデル(ジェネリックがうまく推論されない落とし穴があった領域)が、本モデルではきれいにほぐれます。28章(React 19 新機能まとめ)でこの変化をもう一度確認します。

ジェネリックフック — 軽く触れるだけ #

同じジェネリックパターンがフックにも適用されます。よく使う例が「API レスポンスを保持するフック」ですが、次の 21章 fetch と API レスポンスのタイピングで本格的に見ます。形だけ先に見ておきましょう。

ジェネリックフックのプレビュー
function useResource<T>(url: string): { data: T | null; loading: boolean } {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(url)
      .then((r) => r.json() as Promise<T>)
      .then((d) => {
        setData(d);
        setLoading(false);
      });
  }, [url]);

  return { data, loading };
}

// 使用箇所
const { data } = useResource<User>('/api/me');
// data: User | null

このコードの r.json() as Promise<T> が実はもっとも危険な部分です。サーバーが本当に T の形を返したか検証したことがないからです。21章で zod でこの部分を安全に埋める方法を見ます。

自分でやってみる #

小さな認証 Context を TypeScript で作ってみましょう。

src/AuthContext.tsx:

src/AuthContext.tsx
import { createContext, useContext, useState, useCallback } from 'react';
import type { ReactNode } from 'react';

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

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

const AuthContext = createContext<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  const login = useCallback((name: string) => {
    setUser({ id: crypto.randomUUID(), name });
  }, []);

  const logout = useCallback(() => setUser(null), []);

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

export function useAuth() {
  const v = useContext(AuthContext);
  if (v === null) {
    throw new Error('useAuth は AuthProvider の中でのみ呼び出してください');
  }
  return v;
}

src/App.tsx:

src/App.tsx
import { AuthProvider, useAuth } from './AuthContext';

function LoginForm() {
  const { user, login, logout } = useAuth();

  if (user) {
    return (
      <div>
        <p>こんにちは、{user.name}さん!</p>
        <button onClick={logout}>ログアウト</button>
      </div>
    );
  }

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      const formData = new FormData(e.currentTarget);
      const name = formData.get('name');
      if (typeof name === 'string' && name.length > 0) login(name);
    }}>
      <input name="name" placeholder="名前" />
      <button type="submit">ログイン</button>
    </form>
  );
}

function App() {
  return (
    <AuthProvider>
      <LoginForm />
    </AuthProvider>
  );
}

export default App;

保存して動作を確認してください。その後、<AuthProvider> を外して <LoginForm /> だけ残すと何が起こるかを確認します。useAuth 内の throw が発火してコンソールに明確なエラーが表示されます。キャストで開始するパターン({} as AuthContextValue)がなぜ危険か、直接比較できます。

練習問題 #

  1. 上の AuthContext を state Context と dispatch Context に分離してみてください。user は state Context に、login / logout は dispatch Context に置き、それぞれ useAuthStateuseAuthDispatch ヘルパーを作ります。LoginForm コンポーネントに console.log('rendered') を仕込めば、dispatch だけ使う子が user の変更で再レンダリングされないことを確認できます。
  2. ジェネリック Select コンポーネントを本章の ref-as-prop パターンで作り、string 配列を受け取るケースと { code: string; name: string } 配列を受け取るケースの 2 通りを親から呼び出してみてください。getValue / getLabel のシグネチャが 2 つの呼び出しで自動的に推論されることを確認します。
  3. 多態コンポーネントと 2 つのコンポーネント分離のトレードオフ比較。上の Box のように多態で作ったボタンと、Button / LinkButton の 2 コンポーネントに分けた版をそれぞれ作って呼び出しコードを比較してみてください。自動補完の速度、呼び出しコードの明確さ、エラーメッセージの可読性の 3 観点でどちらがより良く感じるかを短く書いてみます。

一行まとめ: Context の初期値は null + ヘルパーパターン が実務の既定値。state と dispatch を 2 つの Context に分けると再レンダリングが減る。ジェネリックコンポーネントは function List<T>(...) の形(アロー関数は JSX と衝突)。呼び出しの短縮が必要なら T extends WithId のような制約。多態コンポーネントは as prop + ComponentPropsWithoutRef<E> の組み合わせ。強力だがコストがあるので、本当に必要なときだけ。React 19 の ref-as-prop モデルはジェネリックコンポーネントと自然に組み合わさる。

次の章 #

次の 21章 fetch と API レスポンスのタイピングでは、本章で軽く見た useResource の危険なキャスト(r.json() as Promise<T>)を zod で安全に埋める方法を扱います。さらに 4部(モダン Next.js)の Server Components 環境で fetch が持つ意味、つまりクライアントの useEffect + fetch が消える新モデルの土台も合わせて押さえます。

X