TypeScript + React 実践 #3 hooksの型付け
#2 propsとchildrenの型付けでコンポーネントのインターフェースを型として取る方法を見ました。今回はコンポーネントの内側 — 組み込みhookの型をどう扱うかを整理します。
大原則を一行で始めましょう。
推論できれば推論に任せ、推論が足りないところだけ明示する。
この原則が五つのhookすべてに同じく適用されます。
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なのでまともに動作しません。必ず型引数で可能な形を明示します。
type User = { id: string; name: string };
const [user, setUser] = useState<User | null>(null);
setUser({ id: 'u1', name: 'カーティス' }); // OK
setUser(null); // OK (ログアウト)
このパターンは「データロード前」の状態に非常によくあります。次回フォーム状態でもっと頻繁に出会うことになります。
lazy initializerも同じルール #
関数で初期値を作るときも、その関数の戻り値の型がそのまま状態の型になります。
const [todos, setTodos] = useState<Todo[]>(() => loadFromStorage());loadFromStorage()の戻り値の型がTodo[]なら型引数を省略しても良いです。しかし戻り値の型が曖昧だったりunknownなら、明示する方が安全です。
useReducer — actionを絞ることが本当の価値 #
useStateが単純な変更に向いているなら、useReducerはいくつかの種類の変更を一箇所にまとめるときに光ります。TypeScriptと出会うとactionをdiscriminated unionとして取ることで、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>
</>
);
}要点はActionがdiscriminated unionであるということです。
- reducerの
switchの中でaction.type === 'set'の枝でだけaction.valueが見えます。 dispatch({ type: 'set' })のように必要なペイロードが抜けるとコンパイルエラーになります。dispatch({ type: 'unknown' })のようなタイプミスも捕まります。
このセーフティネットはJavaScriptではほぼ不可能です。reducerを使い始めると、TypeScriptが最も光る場面の一つになります。
neverでexhaustivenessチェック
#
actionを追加したのに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; // 新しいactionを忘れるとここで✗
return state;
}
}
}Actionに新しい種類を追加するのにreducerで処理しなければ、_exhaustive: neverの箇所で赤線が出ます。ケースを漏らす危険をコンパイル段階が防いでくれます。
useRef — 二つの用途、二つの型付け #
useRefは使用意図に応じて二つの型付けパターンがあります。
1) DOMノードref #
エレメントに直接付けるrefは初期値をnullにして、型引数でどのエレメントかを伝えます。
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を使います。このときは初期値をそのまま与えて推論に任せます。
function Timer() {
const startedAt = useRef<number>(Date.now());
// .currentは常にnumber — nullチェック不要
return <span>{startedAt.current}</span>;
}DOM refとは違って.currentがnullになることがありません。初期値自体を意味のある値で与えたためです。
参考: 昔は二つの用途を区別するために
MutableRefObjectのような型を直接使う資料も多かったです。最近の@types/reactは初期値で自動的に区別するので、ほぼ気にすることがありません。
forwardRefのref型付け
#
親が子コンポーネントのDOMにrefをかける必要があるときforwardRefを使うのですが、React 19からは単にrefをpropのように受け取ることができ、より簡単になりました。
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は関数の参照同一性を維持するために使います。型の観点では追加でやることがほぼありません。中に渡した関数の型がそのまま推論されます。
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も同様です。中で作った値の型がそのまま戻り値の型になります。
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) 二、三個の値で使用箇所で名前を自由に付けたいとき — タプル
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はオブジェクトの方が読みやすいです。名前がそのまま意味を伝えるからです。useState、useReducerのように二つだけ返す組み込みhookの形と一致する場合のみタプルを使うと考えれば無難です。
まとめ #
今回は次を整理しました。
useStateは初期値で推論。null始まりやunion絞り込みが必要なら型引数明示useReducerはactionをdiscriminated unionに。neverでexhaustivenessチェックuseRefはDOM用(null始まり)と値保持用(意味のある初期値)の二パターン- React 19では
refをpropとして直接受け取れてforwardRefがほぼ不要 useCallback、useMemoは推論を信頼- カスタムhookは二つなら
as constタプル、それ以上はオブジェクト
次の記事(#4 イベントとフォームの型付け)ではイベントオブジェクトとフォーム入力にどんな型を使うか、そして制御/非制御フォームをTypeScriptでどう取るかを扱います。