1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React × TypeScript 実践ガイド【React 19対応】

1
Posted at

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を書きながら必要な順に覚えるほうが早い。

  1. 基本の型付け — props、useState、イベントハンドラ
  2. Union型と絞り込み — ここでTSの威力を実感する
  3. ComponentProps などのユーティリティ型 — 記述量が激減する
  4. z.infer などの型生成 — 手書きの型定義を減らす
  5. ジェネリクス — 最後でいい。共通コンポーネントを作る段階で必要になる

3までで実務の8割はカバーできる。Mapped TypesやConditional Typesは、必要になるまで触らなくていい。


参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?