React × TypeScript 実践ガイド【React 19対応】
結論から
-
React.FCはもう使わない。ただの関数として書いて、propsに型を付けるだけでいい。 -
propsの型は
typeで書く。interfaceでもいいが、Union型やユーティリティ型と組み合わせやすいtypeのほうが実用的。 -
anyを書きたくなったら設計を疑う。逃げるならunknownにして、使う直前で絞り込む。 -
React 19で型が結構変わった。
forwardRef不要、useRefは引数必須、グローバルJSX名前空間の廃止。古い記事のコードはそのままでは通らない。 - PHPの型宣言との一番の違いは「構造的型付け」。名前が違っても形が合えば代入できる。ここを理解すると一気に楽になる。
1. PHPの型とTypeScriptの型は根本的に違う
PHPで5年やっていると、TypeScriptの型は「同じようなもの」に見える。実際は前提が違う。
// PHP:名前的型付け(nominal typing)
class UserId { public function __construct(public int $value) {} }
class PostId { public function __construct(public int $value) {} }
function findUser(UserId $id) {}
findUser(new PostId(1)); // ✗ エラー。クラス名が違う
// TypeScript:構造的型付け(structural typing)
type UserId = { value: number };
type PostId = { value: number };
declare function findUser(id: UserId): void;
findUser({ value: 1 } as PostId); // ✓ 通る。形が同じだから
TypeScriptは「形」だけを見る。クラスやtypeの名前は関係ない。
これは便利な反面、IDとIDを取り違えるようなバグを型で防げない。防ぎたいならブランド型を使う。
type UserId = string & { readonly __brand: 'UserId' };
type PostId = string & { readonly __brand: 'PostId' };
const toUserId = (s: string) => s as UserId;
declare function findUser(id: UserId): void;
findUser('abc' as PostId); // ✗ エラーになる
やりすぎると読みにくいので、取り違えたら本当に困るところだけに使う。
もう1つ重要な違いは、TypeScriptの型は実行時に消えること。PHPの型宣言は実行時にチェックされるが、TSはコンパイル時だけ。APIから返ってきたJSONは型を書いても検証されない。ここは後述するZodの出番。
2. コンポーネントの型付け:React.FC を捨てる
昔の記事に出てくるこの書き方は、今は非推奨。
// ✗ 古い書き方
const Button: React.FC<ButtonProps> = ({ label }) => <button>{label}</button>;
React.FC は暗黙で children を含んでいた(React 18で廃止された)り、ジェネリクスが書きにくかったりと扱いづらい。
// ✓ 今の書き方。ただの関数
type ButtonProps = {
label: string;
variant?: 'primary' | 'ghost';
onClick: () => void;
};
export function Button({ label, variant = 'primary', onClick }: ButtonProps) {
return <button className={variant} onClick={onClick}>{label}</button>;
}
返り値の型(JSX.Element)は書かなくていい。推論に任せる。
children を受け取る
import type { ReactNode } from 'react';
type CardProps = {
title: string;
children: ReactNode; // 文字列・JSX・配列・null 全部OK
};
export function Card({ title, children }: CardProps) {
return (
<section>
<h2>{title}</h2>
{children}
</section>
);
}
ReactNode と ReactElement の使い分け:
| 型 | 受け取れるもの | 使いどころ |
|---|---|---|
ReactNode |
JSX、文字列、数値、配列、null | ほぼ常にこれ |
ReactElement |
JSX要素のみ | 単一の要素を強制したいとき |
迷ったら ReactNode。
3. HTML要素をラップするときの定石
自作の <Input> に placeholder や disabled を毎回追加していく、というのは避けたい。
import type { ComponentProps } from 'react';
type InputProps = ComponentProps<'input'> & {
label: string;
error?: string;
};
export function Input({ label, error, id, ...rest }: InputProps) {
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} aria-invalid={!!error} {...rest} />
{error && <p role="alert">{error}</p>}
</div>
);
}
ComponentProps<'input'> で <input> が受け取れる全属性が入る。onChange も value も ref も書かずに済む。
Reactコンポーネントをラップする場合も同じ要領。
type MyButtonProps = ComponentProps<typeof Button> & { loading?: boolean };
4. React 19で変わった型 —— ここが罠
forwardRef が不要になった
// ✗ React 18までの書き方(19でも動くが冗長)
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => (
<input ref={ref} {...props} />
));
// ✓ React 19。refがただのpropになった
type InputProps = ComponentProps<'input'>;
export function Input({ ref, ...rest }: InputProps) {
return <input ref={ref} {...rest} />;
}
自前でprops型を定義する場合は、ref を明示的に含める。
import type { Ref } from 'react';
type InputProps = {
label: string;
ref?: Ref<HTMLInputElement>; // RefObject と RefCallback の両方を受け付ける
};
useRef は引数必須になった
useRef(); // ✗ エラー。引数がない
useRef(undefined); // ✓
useRef<HTMLInputElement>(null); // ✓ DOM参照はこれ
また、useRef<T>(null) の返り値が RefObject<T> から RefObject<T | null> に変わった。.current を触るときは常に optional chaining を挟む。
const inputRef = useRef<HTMLInputElement>(null);
inputRef.current?.focus(); // ? が必要
MutableRefObject は RefObject に統合されて消えた。使っていたら置換する。
グローバル JSX 名前空間が廃止
Web Componentsの型を拡張していた場合、書き方が変わる。
// ✗ 旧
declare global {
namespace JSX {
interface IntrinsicElements { 'my-element': { foo: string } }
}
}
// ✓ 新
declare module 'react' {
namespace JSX {
interface IntrinsicElements { 'my-element': { foo: string } }
}
}
コード中で JSX.Element と書いていた箇所も React.JSX.Element に変える。
移行はcodemodで
npx types-react-codemod@latest preset-19 ./src
大半は機械的に置換できる。ランタイムの挙動はほぼ変わらず、変更のほとんどが型レベルなので、ビルドが通ればだいたい終わり。
5. Hooksの型付け
useState
const [count, setCount] = useState(0); // number に推論される
const [user, setUser] = useState<User | null>(null); // 初期値がnullなら明示
const [ids, setIds] = useState<string[]>([]); // 空配列も明示
初期値から推論できるなら書かない、できないなら書く。useState(null) は null 型に推論されて後で詰まる。
useReducer
Union型でアクションを定義すると、switch で自動的に絞り込まれる。
type State = { count: number; status: 'idle' | 'loading' };
type Action =
| { type: 'increment' }
| { type: 'set'; payload: number }
| { type: 'reset' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 };
case 'set':
return { ...state, count: action.payload }; // payloadがあると分かる
case 'reset':
return { count: 0, status: 'idle' };
// action.payload はここでは型エラーになる
}
}
このDiscriminated Union(判別可能なUnion)はTypeScriptで最も使う型テクニック。PHPのmatch式に近いが、型が絞り込まれる点が強力。
網羅性チェックも入れておくと、アクションを追加したときに漏れを検出できる。
default: {
const _exhaustive: never = action;
throw new Error(`Unknown action: ${JSON.stringify(_exhaustive)}`);
}
カスタムフックは as const で返す
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle] as const; // これがないと (boolean | (() => void))[] になる
}
const [isOpen, toggleOpen] = useToggle(); // 正しく boolean と関数に分かれる
6. UIの状態はUnion型で表す
これはReact×TSで一番効く設計。
// ✗ ありがちな形。ありえない組み合わせが表現できてしまう
type State = {
loading: boolean;
data: Job[] | null;
error: string | null;
};
// loading:true かつ data:あり かつ error:あり という状態が作れてしまう
// ✓ ありえない状態を型で潰す
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Job[] }
| { status: 'error'; message: string };
こうするとJSX側でも絞り込みが効く。
function JobList({ state }: { state: State }) {
if (state.status === 'loading') return <Spinner />;
if (state.status === 'error') return <p role="alert">{state.message}</p>;
if (state.status === 'idle') return null;
// ここでは state.data が必ず存在すると分かっている
return <ul>{state.data.map((j) => <li key={j.id}>{j.title}</li>)}</ul>;
}
state.data! のような非nullアサーションが要らなくなる。! を書きたくなったら、型の設計を見直すサイン。
7. 外部データは型を「信じない」
ここが実務で一番事故る場所。
// ✗ 嘘の型。実行時には何も検証されていない
const res = await fetch('/api/jobs');
const jobs: Job[] = await res.json(); // res.json() は any
APIの仕様変更やnullの混入があっても、TypeScriptは何も言わない。画面が壊れて初めて気づく。
Zodで境界を守るのが定番。
import { z } from 'zod';
const JobSchema = z.object({
id: z.string(),
title: z.string(),
salary: z.number().nullable(),
publishedAt: z.iso.datetime(),
});
const JobListSchema = z.array(JobSchema);
// 型をスキーマから生成する。二重管理しない
export type Job = z.infer<typeof JobSchema>;
export async function fetchJobs(): Promise<Job[]> {
const res = await fetch('/api/jobs');
if (!res.ok) throw new Error('求人を取得できませんでした');
return JobListSchema.parse(await res.json()); // ここで実行時に検証
}
Laravelの FormRequest が「入ってくるデータを信じない」ためにあるのと同じ発想。信頼できない入力(API、localStorage、URLパラメータ、フォーム)には全部これを噛ませる。
8. tsconfig の最低ライン
{
"compilerOptions": {
"strict": true, // 必須。これを切るならTSを使う意味が薄い
"noUncheckedIndexedAccess": true, // arr[0] が T | undefined になる
"noUnusedLocals": true,
"noUnusedParameters": true,
"jsx": "react-jsx",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true // import type を強制。バンドルサイズに効く
}
}
noUncheckedIndexedAccess は最初うるさく感じるが、配列アクセスのundefinedは実務で頻出するバグなので入れる価値がある。
既存プロジェクトで strict: false から移行するなら、strictNullChecks だけ先に有効化して、ファイル単位で潰していくのが現実的。一気にやると数百件のエラーで心が折れる。
9. 避けたほうがいい書き方
any。型チェックを完全に無効化する。どうしても型が付けられないなら unknown にして、使う直前で絞り込む。
function handle(input: unknown) {
if (typeof input === 'string') {
return input.toUpperCase(); // ここでは string と分かっている
}
}
as の乱用。as は「TypeScriptを黙らせる」だけで、実行時には何も起きない。as が並んでいるファイルは、型が嘘をついている可能性が高い。
enum。TSのenumは実行時にオブジェクトを生成し、tree-shakingが効かない。Union型で足りる。
// ✗
enum Status { Active = 'active', Inactive = 'inactive' }
// ✓
const STATUS = ['active', 'inactive'] as const;
type Status = typeof STATUS[number]; // 'active' | 'inactive'
過剰なジェネリクス。「あとで汎用的に使うかも」で複雑な型を書くと、読めなくなる。2回目に必要になってから抽象化する。
10. 学習の順番
型システム全体を先に学ぶより、Reactを書きながら必要な順に覚えるほうが早い。
- 基本の型付け — props、useState、イベントハンドラ
- Union型と絞り込み — ここでTSの威力を実感する
-
ComponentPropsなどのユーティリティ型 — 記述量が激減する -
z.inferなどの型生成 — 手書きの型定義を減らす - ジェネリクス — 最後でいい。共通コンポーネントを作る段階で必要になる
3までで実務の8割はカバーできる。Mapped TypesやConditional Typesは、必要になるまで触らなくていい。