TypeScript + React 実践 #5 Context とジェネリックコンポーネント

#4 イベントとフォームの型付け までで、コンポーネント内側の最もよくある型付けは整理しました。今回は1段上 — 複数のコンポーネントで共有される値(Context)複数の形のデータを受け取れるコンポーネント(ジェネリック) を扱います。

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 の可能性を除外してくれます。

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

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

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

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

state Context + dispatch Context
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 で頻繁に変わる state を流しているなら一度検討するに値するパターンです。

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

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

ジェネリック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 できちんと型付けするには少し手間がかかります。

最もシンプルな形から見ましょう。

多態コンポーネント — 基本形
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行ずつ解きほぐすと:

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

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

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

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

ジェネリック hook — 軽くだけ #

同じジェネリクスのパターンが hook にも適用されます。よく使う例が「API レスポンスを保持する hook」ですが、これは次回(#6 fetch と API レスポンスの型付け)で本格的に扱います。形だけ先に見ると:

ジェネリックhookプレビュー
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 の形を返したかを検証していないからです。次回 zod でこの部分を安全に埋める方法を扱います。

まとめ #

今回は次を整理しました。

  • Context の初期値は null + ヘルパーパターンが実務のデフォルト
  • State と dispatch を2つの Context に分けると再レンダリングが減る
  • ジェネリックコンポーネントは function List<T>(...) の形。アロー関数は JSX と衝突
  • 呼び出しを短くしたいなら T extends WithId のような制約を入れる
  • 多態コンポーネントは as prop + ComponentPropsWithoutRef<E> の組み合わせ。強力だがコストあり

次回(#6 fetch と API レスポンスの型付け)ではシリーズ最後として、外部 API から来たデータをどう TypeScript 内で安全に扱うか — ジェネリック fetcher と zod ランタイム検証までまとめて整理します。

X