TypeScript + React 実践 #3 hooksの型付け

読了 8分

#2 propsとchildrenの型付けでコンポーネントのインターフェースを型として取る方法を見ました。今回はコンポーネントの内側 — 組み込みhookの型をどう扱うかを整理します。

大原則を一行で始めましょう。

推論できれば推論に任せ、推論が足りないところだけ明示する。

この原則が五つのhookすべてに同じく適用されます。

useState — 初期値で推論される #

最もよくあるパターンから。

useState基本 — 推論
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  // countはnumber、setCountは(n: number | ((prev: number) => number)) => void

  return (
    <button onClick={() => setCount((c) => c + 1)}>{count}</button>
  );
}

useState(0)だけでcount: numberが推論されます。setCountも自動でDispatch<SetStateAction<number>>に取られて、setCount(1)setCount((c) => c + 1)もすべて正確に動作します。

初期値が狭く推論されるケース #

リテラル初期値はたまに広く推論されます。

狭く推論された例 — 意図と異なる
const [status, setStatus] = useState('idle');
// 推論された型: string

setStatus('loading');  // OK
setStatus('done');     // OK
setStatus('foo');      // OK — 実は意図と違う

'idle''loading''done'だけ許可したいなら型引数で明示する必要があります。

ユニオン型を明示
type Status = 'idle' | 'loading' | 'done';
const [status, setStatus] = useState<Status>('idle');

setStatus('done');     // OK
setStatus('foo');      // ✗

初期値がnullのケース #

初期値としてnullを与えると、推論はただのnullなのでまともに動作しません。必ず型引数で可能な形を明示します。

nullで始まる状態
type User = { id: string; name: string };

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

setUser({ id: 'u1', name: 'カーティス' });   // OK
setUser(null);                            // OK (ログアウト)

このパターンは「データロード前」の状態に非常によくあります。次回フォーム状態でもっと頻繁に出会うことになります。

lazy initializerも同じルール #

関数で初期値を作るときも、その関数の戻り値の型がそのまま状態の型になります。

lazy init
const [todos, setTodos] = useState<Todo[]>(() => loadFromStorage());

loadFromStorage()の戻り値の型がTodo[]なら型引数を省略しても良いです。しかし戻り値の型が曖昧だったりunknownなら、明示する方が安全です。

useReducer — actionを絞ることが本当の価値 #

useStateが単純な変更に向いているなら、useReducerいくつかの種類の変更を一箇所にまとめるときに光ります。TypeScriptと出会うとactionをdiscriminated unionとして取ることで、reducerの中で自然に絞り込まれます。

reducerパターン
type State = {
  count: number;
};

type Action =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'set'; value: number };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'set':
      return { count: action.value };  // valueはここでだけ見える
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  return (
    <>
      <span>{state.count}</span>
      <button onClick={() => dispatch({ type: 'increment' })}>+1</button>
      <button onClick={() => dispatch({ type: 'set', value: 100 })}>=100</button>
    </>
  );
}

要点はActiondiscriminated unionであるということです。

  • reducerのswitchの中でaction.type === 'set'の枝でだけaction.valueが見えます。
  • dispatch({ type: 'set' })のように必要なペイロードが抜けるとコンパイルエラーになります。
  • dispatch({ type: 'unknown' })のようなタイプミスも捕まります。

このセーフティネットはJavaScriptではほぼ不可能です。reducerを使い始めると、TypeScriptが最も光る場面の一つになります。

neverでexhaustivenessチェック #

actionを追加したのにreducerで処理を漏らしたらどう気付くでしょうか? defaultの枝でneverを活用するとコンパイル段階で捕まります。

exhaustivenessチェック
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'set':
      return { count: action.value };
    default: {
      const _exhaustive: never = action;  // 新しいactionを忘れるとここで✗
      return state;
    }
  }
}

Actionに新しい種類を追加するのにreducerで処理しなければ、_exhaustive: neverの箇所で赤線が出ます。ケースを漏らす危険をコンパイル段階が防いでくれます。

useRef — 二つの用途、二つの型付け #

useRefは使用意図に応じて二つの型付けパターンがあります。

1) DOMノードref #

エレメントに直接付けるrefは初期値をnullにして、型引数でどのエレメントかを伝えます。

DOM ref
import { useRef, useEffect } from 'react';

function AutoFocusInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} type="text" />;
}

inputRef.currentの型はHTMLInputElement | nullです。マウント前はnullなので、オプショナルチェイニング(?.)が必要です。

2) 変更可能な値の保持 #

状態ではなく「値を持っていて次のレンダリングでも同じオブジェクト」が必要なとき — 例えばsetIntervalのID、前のpropの記憶 — もrefを使います。このときは初期値をそのまま与えて推論に任せます。

値保持ref
function Timer() {
  const startedAt = useRef<number>(Date.now());
  // .currentは常にnumber — nullチェック不要

  return <span>{startedAt.current}</span>;
}

DOM refとは違って.currentnullになることがありません。初期値自体を意味のある値で与えたためです。

参考: 昔は二つの用途を区別するためにMutableRefObjectのような型を直接使う資料も多かったです。最近の@types/reactは初期値で自動的に区別するので、ほぼ気にすることがありません。

forwardRefのref型付け #

親が子コンポーネントのDOMにrefをかける必要があるときforwardRefを使うのですが、React 19からは単にrefをpropのように受け取ることができ、より簡単になりました。

refをpropとして受け取る (React 19)
import type { Ref } from 'react';

type InputProps = {
  ref?: Ref<HTMLInputElement>;
  placeholder?: string;
};

function Input({ ref, placeholder }: InputProps) {
  return <input ref={ref} placeholder={placeholder} />;
}

// 親側で
function Form() {
  const inputRef = useRef<HTMLInputElement>(null);
  return <Input ref={inputRef} placeholder="名前" />;
}

forwardRef自体は依然動作しますが、新しく書くコードは上のパターンの方が短くて型も自然です。

useCallback — シグネチャを保存する #

useCallback関数の参照同一性を維持するために使います。型の観点では追加でやることがほぼありません。中に渡した関数の型がそのまま推論されます。

useCallback推論
import { useCallback, useState } from 'react';

function SearchBar() {
  const [query, setQuery] = useState('');

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setQuery(e.target.value);
    },
    []
  );

  return <input value={query} onChange={handleChange} />;
}

handleChange(e: React.ChangeEvent<HTMLInputElement>) => voidに推論されます。明示する必要がありません。

イベント型は次の記事(#4 イベントとフォームの型付け)でより詳しく扱います。今は「イベントハンドラ関数の仮引数の型はハンドラの中で明示する」とだけ覚えてください。

useMemo — 値の型はそのまま推論される #

useMemoも同様です。中で作った値の型がそのまま戻り値の型になります。

useMemo推論
import { useMemo } from 'react';

type Todo = { id: string; text: string; done: boolean };

function TodoStats({ todos }: { todos: Todo[] }) {
  const stats = useMemo(() => {
    return {
      total: todos.length,
      done: todos.filter((t) => t.done).length,
    };
  }, [todos]);

  // stats: { total: number; done: number }
  return <p>{stats.done} / {stats.total}</p>;
}

推論を信頼して大丈夫です。明示はほぼ必要なく、必要ならuseMemo<{ total: number; done: number }>(...)のように型引数を与えることができます。

カスタムhook — 戻り値の形はタプル vs オブジェクト #

組み込みhookを組み合わせてカスタムhookを作るとき、戻り値をどうするかがしばしば悩みです。正解はないですが、次の二つのガイドが助けになります。

1) 二、三個の値で使用箇所で名前を自由に付けたいとき — タプル

タプル戻り + as const
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);

  return [on, toggle] as const;
}

// 使用箇所で自由に名前を指定
const [isOpen, toggleOpen] = useToggle();

as constが要点です。それが無いと(boolean | (() => void))[]に推論されて、デストラクチャリング時に型が混ざってしまいます。as constを付けると正確に[boolean, () => void]タプルになります。

2) 値が四つ以上または意味の明確な名前があるとき — オブジェクト

オブジェクト戻り
function useTodos() {
  const [items, setItems] = useState<Todo[]>([]);

  const add = useCallback((text: string) => { /* ... */ }, []);
  const remove = useCallback((id: string) => { /* ... */ }, []);
  const toggle = useCallback((id: string) => { /* ... */ }, []);

  return { items, add, remove, toggle };
}

// 使用箇所
const { items, add, remove } = useTodos();

ほとんどの自明でないカスタムhookはオブジェクトの方が読みやすいです。名前がそのまま意味を伝えるからです。useStateuseReducerのように二つだけ返す組み込みhookの形と一致する場合のみタプルを使うと考えれば無難です。

まとめ #

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

  • useStateは初期値で推論。null始まりやunion絞り込みが必要なら型引数明示
  • useReducerはactionをdiscriminated unionに。neverでexhaustivenessチェック
  • useRefはDOM用(null始まり)と値保持用(意味のある初期値)の二パターン
  • React 19ではrefをpropとして直接受け取れてforwardRefがほぼ不要
  • useCallbackuseMemoは推論を信頼
  • カスタムhookは二つならas constタプル、それ以上はオブジェクト

次の記事(#4 イベントとフォームの型付け)ではイベントオブジェクトとフォーム入力にどんな型を使うか、そして制御/非制御フォームをTypeScriptでどう取るかを扱います。

X