定義
ジェネリックコンテキストファクトリは、ジェネリック関数の中で React の context を作り、同じ関数の中で作ったコンポーネントたちにその context をクロージャで掴ませるパターンです。
呼び出し 1 回がそのまま「型もランタイムオブジェクトも完全に別物のコンポーネント一式」になります。このパターンが必要な理由はシンプルで、const 宣言には型パラメータを付けられないからです。
なぜ必要か
Table.Root / Table.Head / Table.Body のようにパーツを組み立てて使う合成コンポーネント(compound component) を、複数のドメインで再利用すると考えてみましょう。注文テーブルは注文型、ユーザーテーブルはユーザー型 — ドメインごとに 1 行(Row)の形が違います。
この型を context に載せて運びたいのですが、文法が邪魔をします。
function f<T>(x: T) {} // ✅ 関数は型パラメータを持てる
type Box<T> = { v: T }; // ✅ 型エイリアスも可能
const Ctx<T> = createContext<T>(null); // ❌ const 宣言は型パラメータ不可
createContext(...) は型ではなく値です。呼んだ瞬間に型が 1 つに確定してしまいます。
回避策が 2 つ思い浮かびますが、どちらも脱落します。
unknown で作って消費地点ごとに as で変換する — パーツの中にドメイン型をハードコードすることになります。再利用のために作ったコンポーネントがドメインに縛られてしまい、目的が消えます。
ドメインごとにファイルをコピーする — テーブルのロジック全体が重複します。バグを直すときはコピーの数だけ直すことになります。
動作原理
解法は、ジェネリック関数の中で context を作り、一緒に作ったコンポーネントたちにクロージャでそれを掴ませることです。
export function createDataTable<Row>() {
type ContextValue = {
columns: readonly ColumnDef<Row>[];
rows: readonly Row[];
getRowId: (row: Row) => string;
};
const Ctx = createContext<ContextValue | null>(null);
const useTable = () => {
const c = useContext(Ctx);
if (!c) throw new Error("Table part must be used inside Table.Root");
return c;
};
function Root<const Cols extends readonly ColumnDef<Row>[]>(props: RootProps<Row, Cols>) {
return <Ctx.Provider value={/* ... */}>{props.children}</Ctx.Provider>;
}
function Body() {
const { columns, rows, getRowId } = useTable(); // ← as なしで Row が生きています
return <tbody>{rows.map((r) => <Row key={getRowId(r)} data={r} />)}</tbody>;
}
return { Root, Head, Body }; // Ctx 自体は外に出しません
}
ここで起きていることは 3 つです。
1 つ目。 Ctx は createDataTable を呼ぶたびに新しく作られます。だから呼び出し 1 回 = 完全に独立した一式 1 つ、です。別のファクトリのパーツを混ぜて使うと、context が見つからずランタイムで throw します。
2 つ目。 Body は Row が何なのかを知らなくて構いません。クロージャで Ctx を掴んでいて、Ctx の型はすでに Row で埋まっているからです。as を 1 行も書かずに型が生きています。
3 つ目。 if (!c) throw で絞り込めば、! の非 null アサーションなしで型が ContextValue に確定します。throw の後では c が null になり得ないと TypeScript が分かっているからです。
ジェネリックが 2 階層になる理由
Row はファクトリを呼ぶときに人が指定し、カラム型 Cols は Root が columns prop から自動で推論します。階層が分かれるのは、TypeScript が型引数を一部だけ指定する文法をサポートしていないからです。1 つのシグネチャに両方入れると、Row を指定した瞬間に Cols も指定しろと要求されます。
実務での適用
// orders/table.ts — モジュール最上位でちょうど 1 回だけ
export const OrderTable = createDataTable<OrderRow>();
// orders/page.tsx
<OrderTable.Root columns={cols} rows={orders} getRowId={(r) => r.orderNo}>
<OrderTable.Head />
<OrderTable.Body />
</OrderTable.Root>
別のドメインのカラムを渡すとコンパイルで止まり、別のファクトリのパーツを混ぜるとランタイムで throw します。
必ず守る 2 つ
ファクトリはモジュール最上位でちょうど 1 回だけ呼びます。
// ❌ コンポーネント本体の中で呼び出し
function OrderPage() {
const Table = createDataTable<OrderRow>(); // レンダーごとに新しい一式
return <Table.Root ...>...</Table.Root>;
}
これはエラーになりません。context の接続も正常です。Provider と consumer が同じクロージャから出てきているからです。
本当の問題は別のところにあります。レンダーのたびに Table.Root が新しい関数 identity を持つようになり、React はそれを別のコンポーネントだと判断してアンマウントしてから再マウントします。 症状がエラーではなく「スクロール位置と入力値とフォーカスがしょっちゅう飛ぶ」なので、原因を見つけるのがずっと難しくなります。
返すオブジェクトに Context 自体を入れません。
ファクトリの中に隔離された as 1 行が安全である唯一の根拠は、「Root がそのコールバックを自分の絞り込んだ値だけで呼ぶ」ということです。Ctx を外に出すと誰でも好きな値を Provider に入れられるようになり、その瞬間にそのアサーションは本物のバグになります。
トレードオフ
得られるものは型安全な再利用です。1 組のロジックでドメインの数だけテーブルを作れて、それぞれが互いに混ざりません。
払うコストは 3 つです。
1 つ目、関数コンポーネントがファクトリの中にあるので、React DevTools で名前がぼやけることがあります。 displayName を明示的に付けておくのが良いです。
2 つ目、バンドル分割が難しくなります。 パーツが 1 つの関数の中にまとまっているので、Body だけ lazy load するようなことができません。
3 つ目、呼び出し位置のルールがコードで強制されません。 モジュール最上位での呼び出しは慣習にすぎないので、新しいメンバーがコンポーネントの中で呼ぶのを型は止められません。lint ルールやレビューで守る必要があります。
ドメインが 1 つだけなら、このコストを払う理由はありません。createContext<OrderTableValue>(null) で普通に書くほうがずっと読みやすいです。
使ってはいけない場合
- ドメインが 1 つか 2 つの場合。 ファクトリのコストが得られるものより大きいです。素直に具体的な型で context を作りましょう。
- パーツを個別に lazy load する必要がある場合。 1 つの関数にまとまっているのでコード分割ができません。
- サーバーコンポーネントの境界をまたぐ構造。 context はクライアント専用なので、ファクトリをサーバーコンポーネントで呼ぶと動きません。
- 型パラメータが実際には変わらない場合。 ジェネリックで開いておいたのに、すべての呼び出し側が同じ型を入れているなら、それは複雑さを増やしただけです。
よくあるミス
- コンポーネント本体の中でファクトリを呼ぶ。 エラーなしで再マウントを繰り返すだけです。状態消失バグを何日も追いかけさせる代表的な落とし穴です。
- 返すオブジェクトに
Ctxを入れる。 隔離の根拠が消えて、内部のアサーションが本物のバグになります。 - ジェネリックコンポーネントに
memoをかける。memoはジェネリックコンポーネントを非ジェネリックにしてしまい、カラム型の推論が丸ごと死にます。memo はジェネリックのない末端コンポーネントにだけ使いましょう。 - パーツを
Rootの外で使うのを型で止めようとする。 止めようとすると結局 prop drilling になって、合成コンポーネントの利点が消えます。ランタイムの throw + テストで守り、残った穴はドキュメントに書くのが妥当なトレードオフです。 displayNameを付けない。 DevTools で全部が匿名関数に見えて、デバッグがつらくなります。
関連概念
- derive-types-from-values —
Rootがカラム配列からソートキーの型を導出する部分の原理 - react-context-render-granularity — この context を購読するパーツの再レンダー範囲の問題
- typescript-variance — context に載せたコールバックのパラメータがどの強さで検査されるか