12k
All articles

React & TypeScript: よりクリーンなコードのための一般的なパターン

ReactのPropsの型付け、refを使ったイベント処理、ユーティリティ型の活用など、コンポーネントの安全性と保守性を高める実践的なTypeScriptパターンを解説する。

OpenReplay Team
OpenReplay Team
React & TypeScript: よりクリーンなコードのための一般的なパターン

TypeScriptを使用してReactアプリケーションを構築する際、同じ課題に繰り返し遭遇するでしょう:propsを正しく型付けする方法、イベントを安全に処理する方法、そして最大限の再利用性を持つコンポーネントを構造化する方法です。これらのパターンは単にコンパイラを満足させるためだけのものではありません。理解しやすく、リファクタリングしやすく、保守しやすいコードを書くためのものです。

この記事では、日常的に使用する最も実用的なReact TypeScriptの一般的なパターンを扱います。コンポーネントを効果的に型付けする方法、オプショナルpropsを処理する方法、イベントとrefsを扱う方法、ユーティリティ型を活用する方法、そしてreducerで判別共用体を使用する方法を学びます。各パターンには、なぜそれがコードを改善するのかを正確に示す焦点を絞った例が含まれています。

重要なポイント

  • propsとstateを明示的に型付けし、インターフェースや型を使用してより良いIntelliSenseとエラー検出を実現
  • オプショナルとデフォルトpropsをTypeScriptのオプショナルプロパティとデフォルトパラメータで処理
  • ユーティリティ型PickComponentPropsなど)を使用して重複を減らし、一貫性を維持
  • イベントとrefsを適切に型付けして型安全なDOM操作を実現
  • 判別共用体をreducerで活用して包括的なアクション処理を実現
  • forwardRefでコンポーネントを合成し、refsを渡す際の型安全性を維持

PropsとStateの型付け

React TypeScriptパターンの基盤は、コンポーネントのpropsを適切に型付けすることから始まります。これによりコンパイル時にエラーを捕捉し、エディタでより良い自動補完を提供します。

基本的なPropsパターン

interface ButtonProps {
  label: string
  onClick: () => void
  disabled?: boolean
}

function Button({ label, onClick, disabled = false }: ButtonProps) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {label}
    </button>
  )
}

このパターンにより、コンポーネントが正しいデータ型を受け取ることが保証されます。インターフェースはコンポーネントが期待するpropsを明確に文書化し、自己文書化されたものにします。

useStateでのState型付け

TypeScriptは通常、state型を正しく推論しますが、複雑なstateは明示的な型付けの恩恵を受けます:

import { useState } from 'react'

interface User {
  id: string
  name: string
  email: string
}

function UserProfile() {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(false) // 型はbooleanとして推論される

  // TypeScriptはuserがnullの可能性があることを認識している
  return user ? <div>{user.name}</div> : <div>Loading...</div>
}

オプショナルとデフォルトPropsの処理

Reactコンポーネントには、オプショナルな設定propsがよくあります。TypeScriptのオプショナルプロパティとデフォルトパラメータを組み合わせることで、クリーンなパターンが作成されます:

interface CardProps {
  title: string
  description?: string
  variant?: 'primary' | 'secondary'
  className?: string
}

function Card({ 
  title, 
  description, 
  variant = 'primary',
  className = ''
}: CardProps) {
  return (
    <div className={`card card-${variant} ${className}`}>
      <h2>{title}</h2>
      {description && <p>{description}</p>}
    </div>
  )
}

このパターンにより、型安全性を維持しながらコンポーネントを柔軟にします。必須propsは明確で、オプショナルpropsには適切なデフォルト値があります。

イベントとRefsの型付け

イベントハンドラーとrefsは、TypeScriptエラーの一般的な原因です。これらのパターンは推測を排除します:

イベントハンドラーパターン

import { useState, ChangeEvent, FormEvent } from 'react'

interface FormProps {
  onSubmit: (data: { email: string; password: string }) => void
}

function LoginForm({ onSubmit }: FormProps) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value)
  }

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    onSubmit({ email, password })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={handleEmailChange} />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
      <button type="submit">Login</button>
    </form>
  )
}

Refパターン

import { useRef, useEffect } from 'react'

function AutoFocusInput() {
  const inputRef = useRef<HTMLInputElement>(null)

  useEffect(() => {
    // TypeScriptはinputRef.currentがnullの可能性があることを認識している
    inputRef.current?.focus()
  }, [])

  return <input ref={inputRef} type="text" />
}

ユーティリティ型の使用

TypeScriptのユーティリティ型は、コードの重複を減らし、アプリケーション全体で型の同期を保ちます。

ComponentPropsパターン

型定義を繰り返すことなく、既存のコンポーネントからpropsを抽出します:

import { ComponentProps } from 'react'

// button要素が受け入れるすべてのpropsを取得
type ButtonProps = ComponentProps<'button'> & {
  variant?: 'primary' | 'secondary'
}

function StyledButton({ variant = 'primary', ...props }: ButtonProps) {
  return <button className={`btn-${variant}`} {...props} />
}

// 独自のコンポーネントからpropsを抽出
type MyButtonProps = ComponentProps<typeof StyledButton>

Pickパターン

コンポーネントを拡張する際に特定のpropsを選択します:

interface UserData {
  id: string
  name: string
  email: string
  role: string
  lastLogin: Date
}

// カードには一部のプロパティのみが必要
type UserCardProps = Pick<UserData, 'name' | 'email'> & {
  onClick?: () => void
}

function UserCard({ name, email, onClick }: UserCardProps) {
  return (
    <div onClick={onClick}>
      <h3>{name}</h3>
      <p>{email}</p>
    </div>
  )
}

forwardRefでのコンポーネント合成

コンポーネントライブラリを構築する際やネイティブ要素をラップする際、forwardRefはrefsを公開しながら型安全性を維持します:

import { forwardRef, useRef, InputHTMLAttributes } from 'react'

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  label: string
  error?: string
}

const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, ...props }, ref) => {
    return (
      <div>
        <label>{label}</label>
        <input ref={ref} {...props} />
        {error && <span className="error">{error}</span>}
      </div>
    )
  }
)

Input.displayName = 'Input'

// 使用時も完全な型安全性を維持
function Form() {
  const inputRef = useRef<HTMLInputElement>(null)
  
  return <Input ref={inputRef} label="Email" type="email" />
}

Reducerでの判別共用体

判別共用体により、reducerアクションが型安全になり、包括的チェックが可能になります:

interface TodoState {
  items: Array<{ id: string; text: string; done: boolean }>
  filter: 'all' | 'active' | 'completed'
}

type TodoAction =
  | { type: 'ADD_TODO'; payload: string }
  | { type: 'TOGGLE_TODO'; payload: string }
  | { type: 'SET_FILTER'; payload: TodoState['filter'] }
  | { type: 'CLEAR_COMPLETED' }

function todoReducer(state: TodoState, action: TodoAction): TodoState {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        items: [...state.items, {
          id: Date.now().toString(),
          text: action.payload,
          done: false
        }]
      }
    
    case 'TOGGLE_TODO':
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload
            ? { ...item, done: !item.done }
            : item
        )
      }
    
    case 'SET_FILTER':
      return { ...state, filter: action.payload }
    
    case 'CLEAR_COMPLETED':
      return {
        ...state,
        items: state.items.filter(item => !item.done)
      }
    
    default:
      // TypeScriptはすべてのケースが処理されることを保証
      const exhaustive: never = action
      return state
  }
}

このパターンにより、すべてのアクション型を確実に処理できます。新しいアクション型を追加した場合、reducerで処理するまでTypeScriptがエラーを出します。

React 19の更新

React 19では、Actions(useActionStateuseFormStatus)、promiseやcontextを消費するためのuse() API、Server Componentsのファーストクラスサポートなどの新機能により、型安全性がさらに重要になります。Actionsを型付けする際は、FormDataの形状とアクションの戻り値を明示的に定義して、サーバーとクライアントのコードが同期を保つようにします。forwardRefから標準propとしてrefを渡すことへの今後の移行も型付けを簡素化します。refを他のpropと同様に扱い、TypeScriptがコンポーネント境界での正確性を保証するようにします。

まとめ

これらのReact TypeScriptの一般的なパターンは、型安全なReactアプリケーションの基盤を形成します。propsとstateを明示的に型付けし、イベントとrefsを正しく処理し、TypeScriptのユーティリティ型を活用することで、より安全で保守しやすいコードを作成できます。パターンは連携して動作します。適切なprops型付けにより、より良いコンポーネント合成が可能になり、判別共用体により複雑なstate論理での包括的処理が保証されます。

基本的なprops型付けから始めて、アプリケーションが成長するにつれてユーティリティ型と高度なパターンを徐々に取り入れてください。適切な型付けへの投資は、より良いIDE支援、より少ないランタイムエラー、より明確なコンポーネント契約を通じて報われます。

よくある質問

コンポーネントpropsでinterfaceとtypeをいつ使い分けるべきですか?

後で拡張や宣言のマージが必要になる可能性がある場合はinterfaceを使用します。共用体、交差型、またはユーティリティ型を扱う場合はtypeを使用します。基本的なpropsではどちらでも問題ありませんので、一つのアプローチを選んで一貫性を保ってください。

children propsを正しく型付けするにはどうすればよいですか?

最大限の柔軟性を得るためにReactNodeを使用してください:children: ReactNode。これは文字列、数値、要素、フラグメント、配列を受け入れます。より具体的なケースでは、ReactElementまたは特定の要素型を使用してください。

高階コンポーネントを型付けする最良の方法は何ですか?

新しいpropsを追加しながら元のコンポーネントのprop型を保持するジェネリック関数を使用してください。ComponentPropsユーティリティ型は、重複なしに既存のコンポーネント型を抽出・拡張するのに役立ちます。

すべてを明示的に型付けするべきか、推論に頼るべきですか?

関数パラメータ、エクスポートされた関数の戻り値型、複雑なstateは明示的に型付けしてください。プリミティブでのuseStateや明らかなイベントハンドラーなどの単純なケースでは、冗長性を減らすためにTypeScriptの推論に任せてください。

Listen to your bugs 🧘, with OpenReplay

See how users use your app and resolve issues fast.
Loved by thousands of developers

We use cookies to improve your experience. By using our site, you accept cookies.