【実務・中級編】タプル型を用いた状態管理:useStateの戻り値が配列である理由 – TypeScript コア・型システムの基礎解析バイブル

なぜ `useState` はオブジェクトではなく配列を返すのか?

Reactの `useState` に初めて触れたとき、多くの開発者がこう思ったはずだ。「なぜ `state` と `setState` を取得するのに、オブジェクトではなく配列(タプル)なのだろう?」と。

// オブジェクト分割代入なら、名前を自由に変えられるのに…なぜ?
const { state: count, setState: setCount } = useState(0); // ❌ できない

もしこれがオブジェクトであれば、プロパティ名で取得できるため、変数名の衝突を気にする必要もないし、順序を覚える必要もない。にもかかわらず、Reactチームはあえて 「可変長の配列」に見えるタプル型 を選択した。

この設計の背後にあるのは、単なる好みではない。TypeScriptの型システムを極限まで活用し、ランタイムのオーバーヘッドをゼロに抑えつつ、極めて厳格な型安全性を担保するための必然のチョイス なのである。

今回は、コードレビューの現場で若手エンジニアによく質問されるこのテーマについて、TypeScriptの型評価メカニズムとコンパイラの挙動から徹底的に紐解いていこう。

—

1. 配列(Array)とタプル(Tuple)の決定的な違い

TypeScriptにおいて、`T[]`(配列)と `[T, U]`(タプル)はまったく異なる生物だ。

  • 配列 (`string[]`): 長さが可変であり、どのインデックスにアクセスしても要素の型は一意(またはその共用体)に決まる。
  • タプル (`[string, number]`): 長さが固定されており、インデックスごとに異なる型が厳密に割り当てられる。

ここで簡単な例を見てみよう。

// 普通の配列の推論
const normalArray = [‘A’, 1]; // 評価結果: (string | number)[]

// タプルの推論(as const を使った場合や、明示的な型注釈)
const tuple = [‘A’, 1] as const; // 評価結果: readonly [“A”, 1]

もし `useState` が単なる配列を返すとしたらどうなるか。型推論は `(State | SetStateAction)[]` になり、要素を取り出すたびに型ガードを行うか、アサーション(`as`)で型をねじ曲げなければならなくなる。これは地獄だ。

TypeScriptの型システムは、タプル型と「constアサーション」、そして「関数からのタプル返却時の型推論」が組み合わさることで、初めて真価を発揮する。

—

2. オブジェクト返却が抱える「命名の呪縛」とパフォーマンス

もし `useState` がオブジェクトを返す設計だったとしよう。

// 妄想上のAPI
function useBadState_01(initial: T) {
return { value: initial, setValue: (v: T) => {} };
}

この設計には、実務上2つの致命的な問題がある。

① 変数名の衝突とリネームのコスト

1つのコンポーネント内で複数のステートを持つことは日常茶飯事だ。

const { value: user, setValue: setUser } = useBadState_01(null);
const { value: isOpen, setValue: setIsOpen } = useBadState_01(false);

オブジェクトの分割代入でリネーム(`value: user`)を毎回書くのは冗長であり、書き忘れた瞬間に変数名が衝突してコンパイルエラーになる。

② ランタイムのコストとメモリ割り当て

これが最大の理由だ。オブジェクトを返すということは、レンダリングのたびに(あるいは初期化時に)キー文字列を持つオブジェクトのLITERAL(リテール)メモリ領域がヒープ上にアロケートされる。

一方、タプル(配列)はランタイムにおいては単なるJavaScriptの配列(内部的には連続したメモリ領域を指すポインタを持つ軽量な構造)である。Reactのように極限までパフォーマンスを追求し、毎フレーム・毎コンポーネントで大量に呼び出されるフックにおいて、オブジェクト生成のガベージコレクション負荷を回避できるタプルの優位性は計り知れない。

—

3. 実践:タプル型を用いた堅牢なカスタムフック設計

このタプルの特性を理解していれば、単なる `useState` の追従にとどまらず、実務で極めて強力なカスタムフックを自作できるようになる。

ここでは、非同期APIリクエストの状態管理を行う、プロダクションクオリティのカスタムフックを実装してみよう。

悪い例:オブジェクトを返し、型がガバガバな設計

// ❌ どこがダメか:使う側で型安全性を保つのが難しく、プロパティ名も冗長
function useAsync_Bad(fn: () => Promise) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

// オブジェクトで返すと、毎度キー名をタイポするリスクがある
return { data, loading, error, execute: () => {} };
}

良い例:厳格なタプル型を返し、かつ配列の要素数に応じた制約を持つ設計

type AsyncState = {
data: T | null;
loading: boolean;
error: Error | null;
};

// 戻り値を「読み取り専用の厳格なタプル」として定義する
type UseAsyncReturn = readonly [
AsyncState,
() => Promise
];

function useAsync(fetcher: () => Promise): UseAsyncReturn {
const [state, setState] = useState>({
data: null,
loading: false,
error: null,
});

const execute = useCallback(async () => {
setState(prev => ({ …prev, loading: true, error: null }));
try {
const data = await fetcher();
setState({ data, loading: false, error: null });
} catch (e) {
setState(prev => ({ …prev, loading: false, error: e instanceof Error ? e : new Error(String(e)) }));
}
}, [fetcher]);

// 返す順序と型が1対1でコンパイル時に保証される
return [state, execute] as const;
}

この設計が圧倒的に優れている理由

1. 変数名を自由につけられる
使う側は、配列のインデックスによる位置依存の分割代入を行うため、変数名をコンテキストに合わせて自由かつ簡潔に命名できる。

// ユーザー取得と商品取得で、名前の衝突を気にせず直感的に命名できる
const [userState, fetchUser] = useAsync(fetchUserData);
const [productState, fetchProduct] = useAsync(fetchProductData);

2. `as const` によるイミュータビリティの強制
戻り値に `as const` を付与することで、TypeScriptのコンパイラはこれを「ただの配列」ではなく「要素の型が固定された読み取り専用タプル (`readonly [AsyncState, () => Promise]`)」として評価する。これにより、誤って要素の順番を入れ替えて代入するようなバグをコンパイル時に完全に封じ込める。

—

4. チーフアーキテクトからの提言:API設計における「配列 vs オブジェクト」の判断基準

コードレビューで「この関数、オブジェクトじゃなくてタプルで返すべきでは?」と指摘すべき境界線はどこにあるのか。以下の基準をチームの共通認識として持ってほしい。

  • オブジェクトを返すべきケース:
  • 返すプロパティの数が4つ以上あり、順序を覚えるのが苦痛になる場合。
  • 呼び出し側が一部のプロパティだけを省略して受け取りたい場合(オブジェクトの分割代入はプロパティ名でマッチするため、不要なものをスキップしやすい)。
  • タプルを返すべきケース:
  • 要素数が少なめ(大体2〜3個)で、役割が明確に対になっている場合(例: `[value, setter]` や `[state, actions]`)。
  • 呼び出し側で必ず任意のわかりやすい変数名にリネームして使いたい場合。
  • Reactのフックのように、毎回のレンダリングでアロケーションコストを極限まで削りたいパフォーマンスクリティカルな場合。

—

まとめ

Reactの `useState` が配列(タプル)を返すのは、決して歴史的経緯の「名残」などではない。

  • 変数名の自由度(使う側が文脈に合わせた名前をつけられる)
  • 型推論の正確性と厳格な順序保証
  • ランタイムにおける圧倒的な軽量性

これらを満たすための、コンパイラを知り尽くした設計者たちによる極めて合理的な選択なのだ。

私たちが日々書くカスタムフックや汎用ライブラリのAPI設計においても、この「タプルの魔力」を使いこなせるようになれば、TypeScriptの型システムは単なる「エラーチェッカー」から「最高級の設計アシスタント」へと変貌する。

次のコードレビューでは、ぜひ「この戻り値、タプルにすべきでは?」という視点を持ってコードを見つめ直してみてほしい。プロダクトのコードベースが、一段上の堅牢性を手に入れるはずだ。

タイトルとURLをコピーしました