目次
18 章

hooks のタイピング — useState · useReducer · useRef

ビルトインフックの型推論と明示する場面。useReducer の discriminated union アクション、useRef の 2 つの用途(DOM ref と値保管)、そして React 19 の ref-as-prop までを扱います。

17章でコンポーネントのインターフェイスを型で捕まえる方法を見ました。本章ではコンポーネントの内側 — ビルトインフックの型をどう扱うか を整理します。

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

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

この原則がすべてのフックに同じく適用されます。17章の discriminated union モデルは本章の useReducer で再び登場します。さらに 5章・13章(カスタムフック)の JavaScript パターンが本章で TypeScript の上に載せ直されます。

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: 'Curtis' });   // OK
setUser(null);                            // OK(ログアウト)

このパターンは「データロード前」状態に非常によくあります。21章(fetch と API レスポンスのタイピング)でフォーム状態としてもっと頻繁に登場します。

lazy initializer も同じルール #

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

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

loadFromStorage() の戻り値型が Todo[] であれば、型引数を省略しても構いません。ただし戻り値型が曖昧だったり unknown であれば、明示する方が安全です。

useReducer — アクションをナローイングするのが本当の価値 #

useState が単純な変更に向くのに対し、useReducer複数種類の変更を一箇所に集めるとき に光ります。TypeScript と組み合わさると、アクションを discriminated union で捕まえることで reducer の中で自然にナローイングされます。17章で押さえたユニオンパターンのもっとも強力な適用方法です。

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 で網羅性チェック #

アクションを追加したのに reducer で処理を漏らした場合、どう気づくでしょうか。default の枝で never を活用するとコンパイル段階で検出されます。

網羅性チェック
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;  // 新しいアクションを忘れるとここで ✗
      return state;
    }
  }
}

Action に新しい種類を追加したのに reducer で処理しないと、_exhaustive: never の部分で赤線になります。ケース漏れのリスクをコンパイル段階で塞いでくれます。

useRef — 2 つの用途、2 つのタイピング #

useRef は使用意図によって 2 つの タイピングパターンがあります。13章(カスタムフック)で軽く触れた ref の 2 つの用途を、本章で正確な型で固めます。

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 になることはありません。初期値自体に意味ある値を与えたためです。

用途初期値.current の型null チェック
DOM ノードnullT | null必要(?.)
値保管意味ある値その値の型不要

古い資料では 2 つの用途を区別するために MutableRefObject のような型を直接使う場面が多かったです。最近の @types/react は初期値で自動的に区別するので、ほぼ気にする必要がありません。

ref を prop として受け取る(React 19) #

親が子コンポーネントの 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 自体は依然として動作しますが、本書は新規コードでは上のパターンだけを使います。forwardRef の古いコードを ref-as-prop に移行する手順は付録 A(旧 React マイグレーション)で扱います。28章(React 19 新機能まとめ)で本モデルの変化全体をもう一度整理します。

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 として推論されます。明示する必要はありません。

イベント型は次の 19章 イベントとフォームのタイピングでさらに詳しく扱います。今は「イベントハンドラ関数の引数型はハンドラの中で明示する」ことだけ覚えておいてください。

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 }>(...) のように型引数を与えられます。

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

ビルトインフックを組み合わせてカスタムフックを作るとき(13章参照)、戻り値をどうするかでよく悩みます。正解はありませんが、次の 2 つのガイドが役に立ちます。

1) 2 〜 3 個の値で、呼び出し側で自由に名前を付けたいとき — タプル

タプル戻り + 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) 値が 4 個以上、あるいは意味の明確な名前があるとき — オブジェクト

オブジェクト戻り
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();

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

自分でやってみる #

useReducer で小さな Todo 管理フックを TypeScript で書いてみましょう。

src/useTodoReducer.ts:

src/useTodoReducer.ts
import { useReducer } from 'react';

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

type State = {
  todos: Todo[];
};

type Action =
  | { type: 'add'; text: string }
  | { type: 'toggle'; id: string }
  | { type: 'remove'; id: string }
  | { type: 'clear' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add':
      return {
        todos: [
          ...state.todos,
          { id: crypto.randomUUID(), text: action.text, done: false },
        ],
      };
    case 'toggle':
      return {
        todos: state.todos.map((t) =>
          t.id === action.id ? { ...t, done: !t.done } : t
        ),
      };
    case 'remove':
      return { todos: state.todos.filter((t) => t.id !== action.id) };
    case 'clear':
      return { todos: [] };
    default: {
      const _exhaustive: never = action;
      return state;
    }
  }
}

export function useTodoReducer() {
  return useReducer(reducer, { todos: [] });
}

src/TodoApp.tsx:

src/TodoApp.tsx
import { useState } from 'react';
import { useTodoReducer } from './useTodoReducer';

function TodoApp() {
  const [{ todos }, dispatch] = useTodoReducer();
  const [text, setText] = useState('');

  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>やること</h2>
      <form onSubmit={(e) => {
        e.preventDefault();
        if (!text) return;
        dispatch({ type: 'add', text });
        setText('');
      }}>
        <input value={text} onChange={(e) => setText(e.target.value)} placeholder="やること" />
        <button type="submit">追加</button>
        <button type="button" onClick={() => dispatch({ type: 'clear' })}>全削除</button>
      </form>
      <ul>
        {todos.map((t) => (
          <li key={t.id}>
            <input type="checkbox" checked={t.done} onChange={() => dispatch({ type: 'toggle', id: t.id })} />
            <span style={{ textDecoration: t.done ? 'line-through' : 'none' }}>{t.text}</span>
            <button onClick={() => dispatch({ type: 'remove', id: t.id })}>削除</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TodoApp;

保存して動作を確認してください。その後、Action に新しい種類 { type: 'reorder'; from: number; to: number } を追加し、reducer で処理しないままビルドしてみてください。_exhaustive: never の部分で赤線が出て、ビルドが止まります。

練習問題 #

  1. 上の useTodoReducerAction を 1 種類({ type: 'rename'; id: string; text: string })追加し、reducer で処理してビルドが再び通るようにしてみてください。網羅性チェックがケース漏れをどう捕まえるかを実際に手に馴染ませてみます。
  2. useState<Status>('idle')Status ユニオンを 'idle' | 'loading' | 'success' | 'error' にし、<StatusBadge status={status} /> コンポーネントで 4 つの状態をすべて分岐処理してみてください。ケースを漏らすと赤線がどう出るかを確認します(never パターン活用)。
  3. useRef の 2 つの用途比較。<AutoFocusInput /> コンポーネントで input に ref をかけてマウント時にフォーカスを当て、同時に useRef<number>(Date.now()) でコンポーネントが作られた時刻を記録して画面に表示してみてください。2 つの ref の .current の型がどう違うか(HTMLInputElement | null vs number)をエディタホバーで確認します。

一行まとめ: useState は初期値で推論。null 開始やユニオンのナローイングが必要なら型引数を明示。useReducer はアクションを discriminated union で、never で網羅性チェック。useRef は DOM 用(null 開始)と値保管用(意味ある初期値)の 2 パターン。React 19 では ref を prop として直接受け取れるので、forwardRef はほぼ不要。useCallbackuseMemo は推論を信頼。カスタムフックは 2 つなら as const タプル、それ以上はオブジェクト。

次の章 #

次の 19章 イベントとフォームのタイピングでは、イベントオブジェクトとフォーム入力にどんな型を使うかを扱います。6章(イベントハンドリング)と 9章(フォーム処理)の JavaScript パターンが TypeScript の上でどうさらに安全になるか、そして 27章(Server Actions とフォーム)で出会う FormData 型の土台までを一緒に押さえます。

X