状態の組み合わせを型で制限する|React props設計

開発

isLoadingtrue なのに data も存在している。そんなバグに遭遇したこと、ありませんか?

optional props を使っていると、本来ありえない状態の組み合わせが許容されてしまいます。この記事では、optional props と discriminated union(判別共用体)の使い分けについて、実務での判断基準を整理します。

Props Soup vs Compound Component がコンポーネントの「構造」の話だったのに対し、今回は「状態の型」の話です。

問題:optional props が許す「ありえない状態」

典型的な非同期データ取得コンポーネントを考えてみましょう。

// ❌ optional props パターン
type DataDisplayProps = {
  isLoading?: boolean
  error?: Error
  data?: User[]
}

function DataDisplay({ isLoading, error, data }: DataDisplayProps) {
  if (isLoading) return <Spinner />
  if (error) return <ErrorMessage error={error} />
  if (data) return <UserList users={data} />
  return null
}

一見問題なさそうですが、この型は以下の状態をすべて許容してしまいます。

// これらがすべて型エラーなしで通る
<DataDisplay isLoading={true} data={users} />        // ローディング中なのにデータある?
<DataDisplay isLoading={true} error={err} />          // ローディング中なのにエラー?
<DataDisplay error={err} data={users} />              // エラーなのにデータある?
<DataDisplay isLoading={true} error={err} data={users} /> // 全部ある?

実際にこのバグ、本番で見たことあります。「なんでローディング中なのにデータ表示されてるの?」って。

筆者

developerway.com では、この問題を「型システムがそれを止めない。結果として、顧客向けUIがめちゃくちゃになる」と表現しています。

解決策:discriminated union で状態を排他的に

discriminated union(判別共用体)を使えば、相互排他的な状態を型レベルで表現できます。

// ✅ discriminated union パターン
type DataDisplayProps =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; error: Error }
  | { status: 'success'; data: User[] }

function DataDisplay(props: DataDisplayProps) {
  switch (props.status) {
    case 'idle':
      return null
    case 'loading':
      return <Spinner />
    case 'error':
      return <ErrorMessage error={props.error} />
    case 'success':
      return <UserList users={props.data} />
  }
}

これで loading 状態のときに dataerror を渡そうとすると、コンパイル時にエラーになります。

// ✅ 型エラー!status が 'loading' のときに data は存在しない
<DataDisplay status="loading" data={users} />

Total TypeScript のワークショップ では、このパターンを「optional の曖昧さを排除し、要件をバリアントごとに明示する」と説明しています。

判断基準:どちらを使うべきか

discriminated union を選ぶべきケース

1. 同じ場所・同じデザインで、文章だけ違う

これが一番シンプルな判断基準です。同じ場所に表示される、デザインも同じ、でもステータスによって表示内容が変わる。そういうコンポーネントはバラバラに作らず、discriminated union で1つにまとめるべきです。

// 通知バナー:表示場所もデザインも同じ、文章だけ違う
type NotificationProps =
  | { type: 'success'; message: string }
  | { type: 'warning'; message: string; action?: () => void }
  | { type: 'error'; message: string; retryable: boolean }

「これ、SuccessNotification と WarningNotification と ErrorNotification に分けるべき?」と迷ったら、同じ文脈かどうかで判断してください。同じ場所で使われて、同じようなデザインなら、1つのコンポーネントにまとめた方がシンプルです。

2. 状態が相互排他的

loading / success / error は同時に起きません。pending / approved / rejected も同様。このような「どれか1つだけ」の状態には discriminated union が最適です。

3. 状態ごとに必要な props が違う

error 状態のときだけ error オブジェクトが必要、success のときだけ data が必要。こういった「状態に紐づく追加情報」がある場合は discriminated union の出番です。

4. 不正な組み合わせがバグを生む

andrewbran.ch の記事では、「型システムは単にバグを早期発見するだけでなく、開発者を正しいパターンに導くべき」と述べられています。不正な状態がUIのバグにつながるなら、型で防ぐ価値があります。

optional props で十分なケース

1. 各 props が独立している

type ButtonProps = {
  bordered?: boolean
  hoverable?: boolean
  disabled?: boolean
}

これらは独立したフラグです。borderedhoverable が同時に true でも問題ありません。むしろ、すべての組み合わせが有効です。

2. 組み合わせに制約がない

4つの boolean があれば 16 通りの組み合わせがありますが、すべてが valid ならわざわざ union にする必要はありません。

3. シンプルな「あるかないか」の情報

type CardProps = {
  title: string
  subtitle?: string  // あってもなくてもいい
  footer?: ReactNode // あってもなくてもいい
}

実装上の注意点

destructuring すると narrowing が効かない

discriminated union を使うときの最大の落とし穴がこれです。

// ❌ destructuring すると narrowing が効かない
function DataDisplay({ status, data, error }: DataDisplayProps) {
  if (status === 'success') {
    // data は undefined | User[] のまま
    // TypeScript は narrowing できない
    return <UserList users={data} /> // エラー!
  }
}

これ、本当に2時間くらいハマりました。「なんで narrowing 効かないの?」ってずっと悩んでた。

筆者

なぜ効かないのか?

destructuring した瞬間、TypeScript は各変数の型を個別に決定します。

type DataDisplayProps =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; error: Error }
  | { status: 'success'; data: User[] }

function DataDisplay({ status, data, error }: DataDisplayProps) {
  // この時点で各変数の型は:
  // status: 'idle' | 'loading' | 'error' | 'success'
  // data:   User[] | undefined  (success にしか存在しないから)
  // error:  Error | undefined   (error にしか存在しないから)
}
変数理由
status'idle' | 'loading' | 'error' | 'success'全バリアントに存在
dataUser[] | undefinedsuccess にしか存在しない
errorError | undefinederror にしか存在しない

この時点で3つの変数は完全に独立。元の union 型との関連性は失われています。

if (status === 'success') {
  // status は 'success' に narrow される ✅
  // でも data は User[] | undefined のまま ❌
  // → 別の変数だから、status のチェックは data に影響しない
}

解決策:props オブジェクトを保持する

// ✅ props オブジェクトを保持する
function DataDisplay(props: DataDisplayProps) {
  if (props.status === 'success') {
    // props 全体が { status: 'success'; data: User[] } に narrow される
    // だから props.data は User[] になる
    return <UserList users={props.data} />
  }
}

props はオブジェクトとして union 型を保持しています。props.status === 'success' をチェックすると、TypeScript は props オブジェクト全体を該当するバリアントに絞り込みます。

TypeScript 4.6 での改善

TypeScript 4.6 で一部改善されました。

// TypeScript 4.6+ では一部のケースで動く
function DataDisplay({ status, data }: DataDisplayProps) {
  if (status === 'success' && data) {
    // data は User[] に narrow される(場合がある)
  }
}

ただし、複雑なケースや rest パターン({ status, ...rest })では依然として効きません。安全を取るなら props オブジェクトを保持する方が確実です。

union が増えすぎたら分割のサイン

andrewbran.ch では、「union だらけのコンポーネントは、複数コンポーネントに分割すべきサイン」と警告しています。

// ❌ union が多すぎる
type ModalProps =
  | { variant: 'confirm'; onConfirm: () => void }
  | { variant: 'alert'; message: string }
  | { variant: 'form'; onSubmit: (data: FormData) => void; fields: Field[] }
  | { variant: 'wizard'; steps: Step[]; currentStep: number }
  | { variant: 'gallery'; images: Image[]; initialIndex: number }
  // ... まだまだ続く

これはもう別コンポーネントに分けるべきです。

// ✅ 分割する
<ConfirmModal onConfirm={...} />
<AlertModal message={...} />
<FormModal onSubmit={...} fields={...} />

複数の独立した選択肢がある場合

「A or B」と「C or D」のような、独立した軸が複数ある場合はどうでしょう?

// ❌ すべての組み合わせを列挙すると爆発する
type ButtonProps =
  | { size: 'sm'; variant: 'primary' }
  | { size: 'sm'; variant: 'secondary' }
  | { size: 'md'; variant: 'primary' }
  | { size: 'md'; variant: 'secondary' }
  | { size: 'lg'; variant: 'primary' }
  | { size: 'lg'; variant: 'secondary' }
  // ... 組み合わせ爆発

この場合は intersection を使います。

// ✅ 独立した軸は intersection で組み合わせる
type SizeProps = { size: 'sm' } | { size: 'md' } | { size: 'lg' }
type VariantProps = { variant: 'primary' } | { variant: 'secondary' }
type CommonProps = { children: ReactNode; onClick?: () => void }

type ButtonProps = CommonProps & SizeProps & VariantProps

これは TypeScript の分配法則によって、すべての有効な組み合わせに展開されます。

実践例:フォームの状態管理

最後に、実務でよく使うパターンを紹介します。

type FormState<T> =
  | { status: 'idle' }
  | { status: 'submitting' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error; lastInput: FormInput }

function useFormSubmit<T>(): [FormState<T>, (input: FormInput) => void] {
  const [state, setState] = useState<FormState<T>>({ status: 'idle' })

  const submit = async (input: FormInput) => {
    setState({ status: 'submitting' })
    try {
      const data = await api.submit(input)
      setState({ status: 'success', data })
    } catch (error) {
      setState({ status: 'error', error, lastInput: input })
    }
  }

  return [state, submit]
}

error 状態のときだけ lastInput を保持しているのがポイントです。再送信時に使えます。optional props だと、どの状態で lastInput が有効なのか分かりません。

別解:複数の Presenter に分割するパターン

discriminated union で1つのコンポーネントにまとめる以外に、状態ごとに Presenter を分けて親が出し分けるパターンもあります。

// パターンA: 1つのコンポーネント + discriminated union
function DataDisplay(props: DataDisplayProps) {
  switch (props.status) {
    case 'idle': return null
    case 'loading': return <Spinner />
    case 'error': return <ErrorMessage error={props.error} />
    case 'success': return <UserList users={props.data} />
  }
}

// パターンB: 複数の Presenter + 親が出し分け
function IdleView() { return null }
function LoadingView() { return <Spinner /> }
function ErrorView({ error }: { error: Error }) { return <ErrorMessage error={error} /> }
function SuccessView({ data }: { data: User[] }) { return <UserList users={data} /> }

function DataDisplayContainer() {
  const state = useDataFetch() // discriminated union を返す hooks

  switch (state.status) {
    case 'idle': return <IdleView />
    case 'loading': return <LoadingView />
    case 'error': return <ErrorView error={state.error} />
    case 'success': return <SuccessView data={state.data} />
  }
}

どちらを選ぶ?

観点1コンポーネント + union複数 Presenter
Storybook1つに全状態の story各 Presenter ごとに story(シンプル)
テスト全状態をモックで切り替え各 Presenter を単独でテスト可能
型安全性props 型で状態を表現各 Presenter の props が最小限
ファイル数1ファイル複数ファイル
状態の一覧性switch 文で一目瞭然親を見ないと全体像が分からない

複数 Presenter が向いているケース

  • 各状態の UI がそれぞれ複雑(50行以上など)
  • Storybook で状態ごとにカタログ化したい
  • Presenter-Container 分離を徹底している

1コンポーネント + union が向いているケース

  • 各状態の UI がシンプル(数行程度)
  • 1つのファイルで全状態を把握したい
  • コンポーネント数を増やしたくない

スタイリング手法による選択

shadcn/ui(Tailwind)を使っている場合、各状態の Presenter を分けた方がいいケースが多いです。理由は className の重複問題。同じスタイルを複数の switch case に書くことになりがちです。

CSS Modules や scoped CSS を使っている場合、スタイルを .module.css で共有できるので、1コンポーネント + union でも問題ありません。

実際に tagged union のコンポーネントで switch 文の各 case 内に直接表示を書いたことがあります。「全部見通せて便利」と思ったんですが、状態が4つ、各状態にバリエーションがあって…気づいたら200行超えてました。Presenter 分けておけばよかった。

筆者

そもそも discriminated union を使うほど状態に変化があるコンポーネントって、それなりに複雑になる傾向があります。最初は「シンプルだから1ファイルで」と思っても、後から各状態に機能が追加されて膨らむことが多い。迷ったら Presenter 分離しておく方が安全かもしれません。

まとめ

optional props と discriminated union、どちらが正解というわけではありません。判断基準は明確です。

  • 状態が相互排他的 → discriminated union
  • 状態ごとに必要な props が違う → discriminated union
  • 各 props が独立している → optional props

個人的には、迷ったら discriminated union を選ぶようにしています。「型が厳しすぎて困る」より「型が緩くてバグが出る」方が痛いからです。

Props Soup vs Compound Component と合わせて、コンポーネント設計の判断基準として活用してください。

Thanks for reading!
TypeScriptReactコンポーネント設計型安全性フロントエンド設計