デザインについての学習メモブログ

React入門 #12 – カスタムフックで処理を再利用しよう

記事内に広告が含まれています。

React入門 #12 – カスタムフックで処理を再利用しよう

これまでuseStateやuseEffectといったReactの組み込みHooksを学んできました。

しかし、複数のコンポーネントで同じロジックを使いたい場合、コードが重複してしまいます。

そこで登場するのがカスタムフックです。

この記事では、カスタムフックの作り方と実践的な活用方法を詳しく学んでいきます。

カスタムフックとは?

カスタムフックの概念やメリットを理解していきましょう。

カスタムフックの概念

カスタムフックは、Reactの組み込みHooksを組み合わせて、再利用可能なロジックを作る仕組みです。

特徴:

  • 関数名は必ずuseで始める
  • 他のHooksを内部で使える
  • JSXを返さない(状態やロジックのみを返す)
  • 複数のコンポーネント間でロジックを共有できる

カスタムフックのメリット

  • コードの再利用性 同じロジックを複数の場所で使える
  • 可読性の向上 複雑なロジックをシンプルな関数として抽出できる
  • テストの容易性 ロジックを独立してテストできる

Reactのカスタムフックは「関数でロジックをまとめて再利用する」というJSやいろんなプログラミング言語にある関数の存在意義そのものです。

ただし、1つだけ大きな違いがあります。

ReactのuseStateuseEffectなどのHooksは、Reactコンポーネントの中でしか呼べないというルールがあります。

useで始めることでReactが「これはHooksを使っていいコンテキストだ」と認識してくれます。

整理すると、

  • JS関数  → 純粋なロジックの再利用
  • カスタムフックReactの状態・ライフサイクルを含むロジックの再利用

カスタムフックは「Hooksが使える特別な関数」と理解するとスッキリします。

基本的なカスタムフック

カスタムフック作成の第一歩として、シンプルなカウンター機能を題材に基本構造を学びます。

ここで身につくパターンは、

  • ショッピングカートの数量管理
  • ページネーション
  • いいね数のカウント

など、あらゆる「数を扱うUI」に応用できます。

例1:カウンターフック

カスタムフック最初の題材としてカウンターを選ぶのには理由があります。

「状態を持ち、それを操作する関数を返す」というカスタムフックの基本構造が、シンプルに凝縮されているからです。

まずはこのパターンをしっかり掴みましょう。

JavaScript版:

  • ポイント①
    引数で初期値を受け取り異なる設定で使い回せる
  • ポイント②
    操作ロジックをフック内に閉じ込める
  • ポイント③
    呼び出し側は必要なものだけ分割代入で取り出せる
JSX
// hooks/useCounter.js
import { useState } from 'react'

function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue)
  //  ↑ ポイント①:引数で初期値を受け取ることで、
  //    同じフックを「10から始まるカウンター」「0から始まるカウンター」など
  //    異なる設定で使い回せる
  
  const increment = () => setCount(count + 1)
  const decrement = () => setCount(count - 1)
  const reset = () => setCount(initialValue)
  //  ↑ ポイント②:操作ロジックをフック内に閉じ込める
  //    コンポーネント側は「何をするか」だけ呼べばいい
  
  return { count, increment, decrement, reset }
  //  ↑ ポイント③:状態と操作関数をオブジェクトでまとめて返す
  //    呼び出し側は必要なものだけ分割代入で取り出せる
}

export default useCounter

作成したカスタムフック(useCounter)の使い方:

初期値を渡すだけでフック内部の実装を知らなくても直感的に使える

JSX
import useCounter from '../hooks/useCounter'

function CounterApp() {
  const { count, increment, decrement, reset } = useCounter(10)
  //  ↑ ポイント:初期値に10を渡すだけで挙動が変わる
  //    フック内部の実装を知らなくても直感的に使える
  //    これがカスタムフックの「インターフェース設計」の旨味  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
      <button onClick={reset}>リセット</button>
    </div>
  )
}

TypeScript版:

interfaceを使うことで「戻り値に型をつける」がポイントです。

  • フックの「仕様書」になる
    コードを読んだ人がimplementationを追わなくても、interfaceを見るだけで「このフックが何を返すか」が一目でわかります。
  • 意図しない戻り値の変更を防ぐ
    フックを修正したとき、interfaceと食い違いが出るとTypeScriptがエラーで教えてくれます。
TSX
// hooks/useCounter.ts
import { useState } from 'react'

interface UseCounterReturn {
  count: number
  increment: () => void
  decrement: () => void
  reset: () => void
}

function useCounter(initialValue: number = 0): UseCounterReturn {
  const [count, setCount] = useState<number>(initialValue)
  
  const increment = (): void => setCount(count + 1)
  const decrement = (): void => setCount(count - 1)
  const reset = (): void => setCount(initialValue)
  
  return { count, increment, decrement, reset }
}

export default useCounter

interfaceの型通りプロパティを取り出さなくても大丈夫なの?

11行目の戻り値の型としてinterfaceの型を指定しているのに

const { count, increment} = useCounter(10)

のように全てにプロパティを取り出さなくてエラーにならない。

これはTypeScriptの「余剰プロパティは無視していい」という設計思想によるものです。

データ取得のカスタムフック

APIとの通信処理は、アプリのあちこちで繰り返し登場します。

このセクションでは、ローディング・エラーハンドリング・データ管理をひとまとめにした汎用フックを作ります。

ユーザー一覧・商品リスト・ニュースフィードなど、あらゆるデータ表示画面でそのまま使い回せるスキルが身につきます。

useFetch – 汎用的なデータ取得フック

データ取得は「通信中・成功・失敗」の3状態を常に管理する必要があります。

これをコンポーネントに直接書くと毎回同じコードが増殖します。

このフックでは、その3状態をひとまとめに扱う設計を学びます。

JavaScript版:

ポイント①:
通信の「3状態」(data / loading / error)を別々のstateで管理

ポイント②:
fetchはHTTPエラー(404など)でもthrowしない

ポイント③:
どんな結果でもloading=falseが確実に実行される

ポイント④:
依存配列にurlを指定 urlが変わるたびに自動で再フェッチされる仕組み

JSX
// hooks/useFetch.js
import { useState, useEffect } from 'react'

function useFetch(url) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  //  ↑ ポイント①:通信の「3状態」を別々のstateで管理
  //    data / loading / error がセットで揃って初めて完成する設計
  
  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true)
        const response = await fetch(url)
        
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`)
        }
        //  ↑ ポイント②:fetchはHTTPエラー(404など)でもthrowしない
        //    response.okで明示的にチェックする必要がある
        
        const json = await response.json()
        setData(json)
        setError(null)     // 成功時はerrorをリセット
      } catch (err) {
        setError(err.message)
        setData(null)      // 失敗時はdataをリセット
      } finally {
        setLoading(false)  // 成功・失敗どちらでもloading解除
        //  ↑ ポイント③:finallyを使うことで
        //    どんな結果でもloading=falseが確実に実行される
      }
    }
    
    fetchData()
  }, [url])
  //    ↑ ポイント④:依存配列にurlを指定
  //      urlが変わるたびに自動で再フェッチされる仕組み  
  return { data, loading, error }
}

export default useFetch

サンプルコードは、固定URLです。urlが変わらないので初回のみに限りの実行となります。(呼び出したコンポーネントがレンダリングされ場合実行される)

作成したカスタムフック(useFetch)の使い方:

ポイントは以下の3つです。

  • 早期returnで3状態を上から順に処理
  • コンポーネントの本体(正常系)をスッキリ保てる
  • この「ガード節パターン」はReactの定番の書き方

ガード節パターンは「早期returnで異常系を先に処理し、本体をスッキリ保つ」書き方のパターンです。

ガード節パターン無しの場合はif文のネストが深くなる

JSX
import useFetch from '../hooks/useFetch'

function UserList() {
  const { data, loading, error } = useFetch('https://jsonplaceholder.typicode.com/users')
  
  if (loading) return <p>読み込み中...</p>
  if (error) return <p>エラー: {error}</p>
  if (!data) return null
  //  ↑ ポイント:早期returnで3状態を上から順に処理
  //    コンポーネントの本体(正常系)をスッキリ保てる
  //    この「ガード節パターン」はReactの定番の書き方
  
  return (
    <ul>
      {data.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

export default UserList

8行目”if (!data) return null”は通信は成功したがデータが空の場合の対処

TypeScript版:

TSX
// hooks/useFetch.ts
import { useState, useEffect } from 'react'

interface UseFetchReturn<T> {
  data: T | null
  loading: boolean
  error: string | null
}

function useFetch<T>(url: string): UseFetchReturn<T> {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState<boolean>(true)
  const [error, setError] = useState<string | null>(null)
  
  useEffect(() => {
    const fetchData = async (): Promise<void> => {
      try {
        setLoading(true)
        const response = await fetch(url)
        
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`)
        }
        
        const json: T = await response.json()
        setData(json)
        setError(null)
      } catch (err) {
        setError(err instanceof Error ? err.message : '不明なエラー')
        setData(null)
      } finally {
        setLoading(false)
      }
    }
    
    fetchData()
  }, [url])
  
  return { data, loading, error }
}

export default useFetch

TS版の作成したカスタムフック(useFetch)の使用例

10行目にinterface Userの配列を型としています。

これによりdataプロパティのデータ型として確定(useFetch<T>のジェネリクスの型を確定)しています。

型チェックが行われ安全にデータの受け渡しができます。

TSX
import useFetch from '../hooks/useFetch'
// 型を明示的に渡す必要がある
interface User {
  id: number
  name: string
  email: string
}

function UserList() {
  const { data, loading, error } = useFetch<User[]>(
    'https://jsonplaceholder.typicode.com/users'
  )

  if (loading) return <p>読み込み中...</p>
  if (error) return <p>エラー: {error}</p>
  if (!data) return null //通信はOKだがデータは空の場合
  
  //  ↑ ポイント:早期returnで3状態を上から順に処理
  //    コンポーネントの本体(正常系)をスッキリ保てる
  //    この「ガード節パターン」はReactの定番の書き方

  return (
    <ul>
      {data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

export default UserList

ローカルストレージのカスタムフック

「テーマ設定を保存したい」「ページを閉じてもフォームの入力を残したい」——そんなニーズに答えるのがこのフックです。

ブラウザのローカルストレージをReactのstateと同期させる仕組みを学ぶことで、ユーザー体験を高める永続化機能を手軽に実装できるようになります。

useLocalStorage – 永続化されたstate

JavaScript版:

JSX
// hooks/useLocalStorage.js
import { useState, useEffect } from 'react'

function useLocalStorage(key, initialValue) {
  // 初期値をローカルストレージから取得
  const [value, setValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key)
      return item ? JSON.parse(item) : initialValue
    } catch (error) {
      console.error('Error reading from localStorage:', error)
      return initialValue
    }
  })
  
  // valueが変わったらローカルストレージに保存
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value))
    } catch (error) {
      console.error('Error writing to localStorage:', error)
    }
  }, [key, value])
  
  return [value, setValue]
}

export default useLocalStorage

作成したカスタムフック使い方:

JSX
import useLocalStorage from '../hooks/useLocalStorage'

function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light')
  const [language, setLanguage] = useLocalStorage('language', 'ja')
  
  return (
    <div>
      <select value={theme} onChange={(e) => setTheme(e.target.value)}>
        <option value="light">ライト</option>
        <option value="dark">ダーク</option>
      </select>
      
      <select value={language} onChange={(e) => setLanguage(e.target.value)}>
        <option value="ja">日本語</option>
        <option value="en">English</option>
      </select>
      
      <p>テーマ: {theme}</p>
      <p>言語: {language}</p>
    </div>
  )
}

export default Settings

TypeScript版:

TSX
// hooks/useLocalStorage.ts
import { useState, useEffect } from 'react'

function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
  const [value, setValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key)
      return item ? JSON.parse(item) : initialValue
    } catch (error) {
      console.error('Error reading from localStorage:', error)
      return initialValue
    }
  })
  
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value))
    } catch (error) {
      console.error('Error writing to localStorage:', error)
    }
  }, [key, value])
  
  return [value, setValue]
}

export default useLocalStorage

作成したカスタムフック使い方

コードを省略していますが、2行目のように型チェック機能するのがJSとの違いです。

TSX
function Settings() {
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light')
  //                                        ↑ ユニオン型を渡すことで
  //                                          取りうる値を2択に絞れる

  const [count, setCount] = useLocalStorage<number>('count', 0)
  //                                        ↑ initialValueがnumberでも
  //                                          明示することで意図が伝わる

  setTheme('blue')  // ❌ 'light' | 'dark' 以外はエラー
  setTheme('dark')  // ✅
  setCount('hello') // ❌ number以外はエラー
  setCount(1)       // ✅
  
  }
  
  export default Settings

💡 型チェックはカスタムフックで?コンポーネント側で?どっちがいい?

設計次第ですが、役割分担の考え方があります。

  • カスタムフック側で型を守る(入口で弾く)
  • コンポーネント側で型を指定する(使う側が決める)

実務での一般的な役割分担

カスタムフック側の責任

└ 「どう動くか」のロジックを保証する

└ 明らかにおかしい型を弾く(Tの制約)

コンポーネント側の責任

└ 「何の型を扱うか」を決める

└ ユニオン型など用途に合わせた型を指定する

判断の基準になる問い

「この制約はどこで使っても共通か?」

  • → YES → フック側に書く
  • → NO → コンポーネント側に書く

例:localStorageに関数は保存できない → どこで使っても共通のルール → フック側で弾く

例:’light’ | ‘dark’ の2択 → このコンポーネント固有のルール → コンポーネント側で指定

「フックを汎用的に保ちつつ、使う側が型で意図を表明する」がTypeScriptらしい設計です。

フォーム管理のカスタムフック

ログインフォームや会員登録フォームなど、入力値の管理・バリデーション・送信処理は毎回書くのが面倒なコードの代表格です。

このセクションでは、それらをまるごと抽象化したフックを作成

どんなフォームにも使い回せる汎用的な設計力が身につきます。

useForm – フォーム入力を簡単に管理

JavaScript版:

JSX
// hooks/useForm.js
import { useState } from 'react'

function useForm(initialValues, onSubmit) {
  const [values, setValues] = useState(initialValues)
  const [errors, setErrors] = useState({})
  const [isSubmitting, setIsSubmitting] = useState(false)
 //  ↑ ポイント①:フォームの状態を3つに分けて管理
  //    values      → 各inputの入力値
  //    errors      → バリデーションエラーメッセージ
  //    isSubmitting → 送信中フラグ(二重送信防止)
  
  const handleChange = (e) => {
    const { name, value, type, checked } = e.target
    setValues({
      ...values,
      [name]: type === 'checkbox' ? checked : value
    })
  }
  //  ↑ ポイント②:inputのname属性をキーに値を更新
  //    [name]の動的キーにより、フィールドが何個あっても
  //    このhandleChange1つで全て対応できる
  //    checkboxだけcheckedを使う分岐を忘れずに
  
  const handleSubmit = async (e) => {
    e.preventDefault()
    //  ↑ ポイント③:ページリロードを防ぐ必須の1行
    setIsSubmitting(true)
    
    try {
      await onSubmit(values)
      //  ↑ ポイント④:送信処理はフック外(引数のonSubmit)に委譲
      //    フックは「フォームの状態管理」だけに責任を持つ
      //    実際のAPI呼び出しはコンポーネント側が決める設計
    } catch (err) {
      setErrors({ submit: err.message })
    } finally {
      setIsSubmitting(false)
      //  ↑ ポイント⑤:成功・失敗どちらでもisSubmittingを解除
      //    finallyで確実に二重送信防止フラグをリセットする
    }
  }
  
  const resetForm = () => {
    setValues(initialValues)
    setErrors({})
    //  ↑ ポイント⑥:initialValuesに戻すことでフォームをリセット
    //    errorsもセットでリセットするのを忘れずに
  }
  
  return {
    values,
    errors,
    isSubmitting,
    handleChange,
    handleSubmit,
    resetForm,
    setErrors
  }
  //  ↑ ポイント⑦:状態と操作関数をまとめて返す
  //    コンポーネント側は必要なものだけ取り出して使える
}

export default useForm

作成したカスタムフック使い方:

JSX
import useForm from '../hooks/useForm'

function LoginForm() {
  const initialValues = {
    email: '',
    password: '',
    rememberMe: false
    //  ↑ ポイント①:initialValuesのキーがそのままinputのname属性と対応する
    //    フィールドを追加したいときはここにキーを足すだけでOK
  }
  
  const handleLogin = async (values) => {
    // ログイン処理
    console.log('ログイン:', values)
    //  ↑ ポイント②:送信処理だけをここに書く
    //    フォームの状態管理はuseFormに任せているので
    //    このコンポーネントはログイン処理だけに集中できる
  }
  
  const { values, handleChange, handleSubmit, isSubmitting } = useForm(
    initialValues,
    handleLogin
  )
  //  ↑ ポイント③:必要なものだけ取り出して使う
  //    errorsやresetFormが不要な場面では取り出さなくていい
  return (
    <form onSubmit={handleSubmit}>
      <input
        name="email"// ← initialValuesのキーと一致させる
        type="email"
        value={values.email}
        onChange={handleChange}
        placeholder="メール"
      />
      
      <input
        name="password"// ← initialValuesのキーと一致させる
        type="password"
        value={values.password}
        onChange={handleChange}
        placeholder="パスワード"
      />
      
      <label>
        <input
          name="rememberMe"// ← initialValuesのキーと一致させる
          type="checkbox"
          checked={values.rememberMe}
          onChange={handleChange}
        />
        ログイン状態を保存
      </label>
      
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  )
}

export default LoginForm

TypeScript版:

TSX
// hooks/useForm.ts
import { useState } from 'react'

interface UseFormReturn<T> {
  values: T
  errors: Record<string, string>
  isSubmitting: boolean
  handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
  handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void
  resetForm: () => void
  setErrors: (errors: Record<string, string>) => void
}

function useForm<T extends Record<string, any>>(
  initialValues: T,
  //  ↑ 型チェック①:initialValuesの型がTとして確定する
  //    コンポーネントで渡した型と食い違うとここでエラーになる
  onSubmit: (values: T) => Promise<void>
  //  ↑ 型チェック②:onSubmitの引数もTで縛られる
  //    initialValuesと異なる型のvaluesを渡そうとするとエラーになる
): UseFormReturn<T> {
//   ↑ 型チェック③:戻り値もTで縛られる
//     returnするオブジェクトがUseFormReturn<T>と
//     食い違うとここでエラーになる
  const [values, setValues] = useState<T>(initialValues)
  const [errors, setErrors] = useState<Record<string, string>>({})
  const [isSubmitting, setIsSubmitting] = useState<boolean>(false)
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
  //                    ↑ 型チェック④:eはinputのChangeEventのみ受け付ける
 //                              !! textarea・selectには別の型が必要になる
    const { name, value, type, checked } = e.target
    setValues({
      ...values,
      [name]: type === 'checkbox' ? checked : value
    })
  }
  
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
                             //型チェック⑤:eはformのSubmitEventのみ受け付ける
                              //  onClickなど別のイベントを渡すとエラーになる
    e.preventDefault()
    setIsSubmitting(true)
    
    try {
      await onSubmit(values)
    } catch (err) {
      setErrors({ submit: err instanceof Error ? err.message : '不明なエラー' })
      //                    ↑ 型チェック⑥:TSではcatchのerrは "unknown型"
      //                      instanceofでErrorと確定させてからmessageを使う
      //                      JSではこのチェックなしでもエラーにならない
    } finally {
      setIsSubmitting(false)
    }
  }
  
  const resetForm = (): void => {
    setValues(initialValues)
    setErrors({})
  }
  
  return {
    values,
    errors,
    isSubmitting,
    handleChange,
    handleSubmit,
    resetForm,
    setErrors
  }
}

export default useForm

作成したカスタムフック使い方:

TSX
import useForm from '../hooks/useForm'

// フォームの値の型をコンポーネント側で定義する
interface LoginFormValues extends Record<string, unknown> {
  email: string
  password: string
  rememberMe: boolean
  //  ↑ 型チェック①:フォームの値として取りうる型を明示
  //    extendsでRecord<string, unknown>の "制約を満たしつつ"
  //    各フィールドの型も保証されている
}

function LoginForm() {
  const initialValues: LoginFormValues = {
    email: '',
    password: '',
    rememberMe: false,
  }

  const handleLogin = async (values: LoginFormValues): Promise<void> => {
  //                                ↑ 型チェック②:valuesはLoginFormValues型
  //                                  values.emailはstringと確定しているので
  //                                  stringのメソッドが使える
    console.log('ログイン:', values)
  }

  const { values, handleChange, handleSubmit, isSubmitting } =
    useForm<LoginFormValues>(
      // ↑ ポイント③:
      //    ”使う側が” フォームの"型"を決める
      //    フックはどんな型でも受け取れる
      initialValues,
      handleLogin
    )

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="email"
        type="email"
        value={values.email} // ✅ string と確定している
        onChange={handleChange}
        placeholder="メール"
      />

      <input
        name="password"
        type="password"
        value={values.password} // ✅ string と確定している
        onChange={handleChange}
        placeholder="パスワード"
      />

      <label>
        <input
          name="rememberMe"
          type="checkbox"
          checked={values.rememberMe as boolean} // ポイント④:
          //                                        unknownなので型アサーションが必要
          onChange={handleChange}
        />
        ログイン状態を保存
      </label>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  )
}

export default LoginForm

特にポイント③でデータ型を選定し、型チェックで安全を担保されているのがポイントです。

ウィンドウサイズのカスタムフック

スマホとPCで表示を切り替えたい場面は多くあります。

このフックを使えば、CSSメディアクエリだけでは難しい「Javascriptレベルでの画面幅の判定」が簡単に実現できます。

レスポンシブなコンポーネント設計に必要な基礎スキルが身につきます。

useWindowSize – レスポンシブ対応

JavaScript版:

JSX
// hooks/useWindowSize.js
import { useState, useEffect } from 'react'

function useWindowSize() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  })
  
  useEffect(() => {
    const handleResize = () => {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      })
    }
    
    window.addEventListener('resize', handleResize)
    
    return () => window.removeEventListener('resize', handleResize)
  }, [])
  
  return windowSize
}

export default useWindowSize

作成したカスタムフック使い方:

JSX
import useWindowSize from '../hooks/useWindowSize'

function ResponsiveComponent() {
  const { width, height } = useWindowSize()
  
  return (
    <div>
      <p>ウィンドウサイズ: {width} x {height}</p>
      {width < 768 ? (
        <p>モバイル表示</p>
      ) : (
        <p>デスクトップ表示</p>
      )}
    </div>
  )
}

export default ResponsiveComponent

TypeScript版:

TSX
// hooks/useWindowSize.ts
import { useState, useEffect } from 'react'

interface WindowSize {
  width: number
  height: number
}

function useWindowSize(): WindowSize {
  const [windowSize, setWindowSize] = useState<WindowSize>({
    width: window.innerWidth,
    height: window.innerHeight
  })
  
  useEffect(() => {
    const handleResize = (): void => {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      })
    }
    
    window.addEventListener('resize', handleResize)
    
    return () => window.removeEventListener('resize', handleResize)
  }, [])
  
  return windowSize
}

export default useWindowSize

デバウンスのカスタムフック

検索ボックスに文字を打つたびにAPIが叩かれると、パフォーマンスに悪影響が出ます。

このセクションでは「一定時間入力が止まったときだけ処理を実行する」デバウンス処理を学びます。

検索・サジェスト・自動保存など、入力に連動するあらゆる機能の最適化に活かせます。

useDebounce – 入力遅延処理

JavaScript版:

JSX
// hooks/useDebounce.js
import { useState, useEffect } from 'react'

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value)
  
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value), useEffect
    }, delay)
    
    return () => {
      clearTimeout(handler)
    }
  }, [value, delay])
  
  return debouncedValue
}

export default useDebounce

作成したカスタムフック使い方:

JSX
import { useState, useEffect } from 'react'
import useDebounce from '../hooks/useDebounce'

function SearchBox() {
  const [searchTerm, setSearchTerm] = useState('')
  const debouncedSearchTerm = useDebounce(searchTerm, 500)
  
  // debouncedSearchTermが変わったときのみAPI呼び出し
  useEffect(() => {
    if (debouncedSearchTerm) {
      console.log('検索:', debouncedSearchTerm)
      // API呼び出し
    }
  }, [debouncedSearchTerm])
  
  return (
    <input
      type="text"
      value={searchTerm}
      onChange={(e) => setSearchTerm(e.target.value)}
      placeholder="検索"
    />
  )
}

export default SearchBox

TypeScript版:

TSX
// hooks/useDebounce.ts
import { useState, useEffect } from 'react'

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value)
  
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)
    
    return () => {
      clearTimeout(handler)
    }
  }, [value, delay])
  
  return debouncedValue
}

export default useDebounce

トグルのカスタムフック

モーダル・ドロップダウン・アコーディオンなど、「開く/閉じる」を繰り返すUIはReactアプリの至る所に登場します。

このフックを使えばわずか1行で真偽値の切り替えが完結し、コードが驚くほどスッキリします。

useToggle – 真偽値の切り替え

JavaScript版:

JSX
// hooks/useToggle.js
import { useState } from 'react'

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue)
  
  const toggle = () => setValue(prev => !prev)
  const setTrue = () => setValue(true)
  const setFalse = () => setValue(false)
  
  return [value, { toggle, setTrue, setFalse }]
}

export default useToggle

作成したカスタムフック使い方:

JSX
import useToggle from '../hooks/useToggle'

function Modal() {
  const [isOpen, { toggle, setTrue, setFalse }] = useToggle(false)
  
  return (
    <div>
      <button onClick={setTrue}>開く</button>
      
      {isOpen && (
        <div className="modal">
          <p>モーダルの内容</p>
          <button onClick={setFalse}>閉じる</button>
        </div>
      )}
      
      <button onClick={toggle}>トグル</button>
    </div>
  )
}

export default Modal

TypeScript版:

TSX
// hooks/useToggle.ts
import { useState } from 'react'

interface UseToggleReturn {
  toggle: () => void
  setTrue: () => void
  setFalse: () => void
}

function useToggle(initialValue: boolean = false): [boolean, UseToggleReturn] {
  const [value, setValue] = useState<boolean>(initialValue)
  
  const toggle = (): void => setValue(prev => !prev)
  const setTrue = (): void => setValue(true)
  const setFalse = (): void => setValue(false)
  
  return [value, { toggle, setTrue, setFalse }]
}

export default useToggle

配列操作のカスタムフック

Todoリスト・タグ入力・お気に入り機能など、配列データを扱うUIは非常に多くあります。

追加・削除・更新・絞り込みといった操作をフックに集約することで、コンポーネント側のロジックを最小限に抑えるスキルが身につきます。

useArray – 配列の操作を簡単に

JavaScript版:

JSX
// hooks/useArray.js
import { useState } from 'react'

function useArray(initialArray) {
  const [array, setArray] = useState(initialArray)
  
  const push = (element) => {
    setArray(prev => [...prev, element])
  }
  
  const filter = (callback) => {
    setArray(prev => prev.filter(callback))
  }
  
  const update = (index, newElement) => {
    setArray(prev => [
      ...prev.slice(0, index),
      newElement,
      ...prev.slice(index + 1)
    ])
  }
  
  const remove = (index) => {
    setArray(prev => [
      ...prev.slice(0, index),
      ...prev.slice(index + 1)
    ])
  }
  
  const clear = () => {
    setArray([])
  }
  
  return { array, set: setArray, push, filter, update, remove, clear }
}

export default useArray

作成したカスタムフック使い方:

JSX
import { useState } from 'react'
import useArray from '../hooks/useArray'

function TodoList() {
  const { array: todos, push, remove, clear } = useArray([])
  const [input, setInput] = useState('')
  
  const handleAdd = () => {
    if (input.trim()) {
      push({ id: Date.now(), text: input })
      setInput('')
    }
  }
  
  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="新しいTodo"
      />
      <button onClick={handleAdd}>追加</button>
      <button onClick={clear}>全削除</button>
      
      <ul>
        {todos.map((todo, index) => (
          <li key={todo.id}>
            {todo.text}
            <button onClick={() => remove(index)}>削除</button>
          </li>
        ))}
      </ul>
    </div>
  )
}

export default TodoList

TypeScript版:

TSX
// hooks/useArray.ts
import { useState } from 'react'

interface UseArrayReturn<T> {
  array: T[]
  set: (array: T[]) => void
  push: (element: T) => void
  filter: (callback: (item: T) => boolean) => void
  update: (index: number, newElement: T) => void
  remove: (index: number) => void
  clear: () => void
}

function useArray<T>(initialArray: T[]): UseArrayReturn<T> {
  const [array, setArray] = useState<T[]>(initialArray)
  
  const push = (element: T): void => {
    setArray(prev => [...prev, element])
  }
  
  const filter = (callback: (item: T) => boolean): void => {
    setArray(prev => prev.filter(callback))
  }
  
  const update = (index: number, newElement: T): void => {
    setArray(prev => [
      ...prev.slice(0, index),
      newElement,
      ...prev.slice(index + 1)
    ])
  }
  
  const remove = (index: number): void => {
    setArray(prev => [
      ...prev.slice(0, index),
      ...prev.slice(index + 1)
    ])
  }
  
  const clear = (): void => {
    setArray([])
  }
  
  return { array, set: setArray, push, filter, update, remove, clear }
}

export default useArray

作成したカスタムフック使い方:

jsとの違いは、10行目useArrayの型です。Todo interfaceを定義して配列のTの型を確定しています。

TSX
import { useState } from 'react'
import useArray from '../hooks/useArray'

interface Todo {
  id: number
  text: string
}

function TodoList() {
  const { array: todos, push, remove, clear } = useArray<Todo>([])
  const [input, setInput] = useState('')

  const handleAdd = () => {
    if (input.trim()) {
      push({ id: Date.now(), text: input })
      setInput('')
    }
  }

  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="新しいTodo"
      />
      <button onClick={handleAdd}>追加</button>
      <button onClick={clear}>全削除</button>

      <ul>
        {todos.map((todo, index) => (
          <li key={todo.id}>
            {todo.text}
            <button onClick={() => remove(index)}>削除</button>
          </li>
        ))}
      </ul>
    </div>
  )
}

export default TodoList

カスタムフックのベストプラクティス

カスタムフックは「動けばいい」で終わらせず、チームで使い回せる品質を目指すことが大切です。

このセクションでは命名・責務の分割・戻り値の設計など、実務で評価されるコードを書くための指針を整理します。

プラクティス1: 関数名は必ずuseで始める

❌ 悪い例:

JSX
function fetchData() { ... }  // ダメ
function getCounter() { ... }  // ダメ

✅ 良い例:

JSX
function useFetch() { ... }
function useCounter() { ... }

プラクティス2: 単一責任の原則

❌ 悪い例(多くの責任を持つ):

JSX
function useEverything() {
  // データ取得
  // フォーム管理
  // 認証
  // ...
}

✅ 良い例(単一の責任):

JSX
function useFetch() { ... }
function useForm() { ... }
function useAuth() { ... }

プラクティス3: 適切な戻り値

配列を返す場合:

JSX
const [value, setValue] = useState()  // React標準
const [isOpen, toggle] = useToggle()  // カスタムフック

オブジェクトを返す場合:

JSX
const { data, loading, error } = useFetch()
const { values, handleChange, handleSubmit } = useForm()

実践:完全なユーザー管理フック

ここまで学んだ個別のフックを組み合わせ、データ取得・検索・追加・削除・更新をすべて備えた本格的なユーザー管理機能を実装します。

実務レベルのカスタムフック設計の全体像が掴め、実際のプロジェクトにすぐ持ち込めるパターンが身につきます。

JavaScript版:

JSX
// hooks/useUsers.js
import { useState, useEffect } from 'react'

function useUsers() {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  const [searchTerm, setSearchTerm] = useState('')
  
  // データ取得
  useEffect(() => {
    const fetchUsers = async () => {
      try {
        setLoading(true)
        const response = await fetch('https://jsonplaceholder.typicode.com/users')
        const data = await response.json()
        setUsers(data)
      } catch (err) {
        setError(err.message)
      } finally {
        setLoading(false)
      }
    }
    
    fetchUsers()
  }, [])
  
  // フィルタリング
  const filteredUsers = users.filter(user =>
    user.name.toLowerCase().includes(searchTerm.toLowerCase())
  )
  
  // ユーザー追加
  const addUser = (user) => {
    setUsers(prev => [...prev, { ...user, id: Date.now() }])
  }
  
  // ユーザー削除
  const removeUser = (id) => {
    setUsers(prev => prev.filter(user => user.id !== id))
  }
  
  // ユーザー更新
  const updateUser = (id, updatedUser) => {
    setUsers(prev => prev.map(user =>
      user.id === id ? { ...user, ...updatedUser } : user
    ))
  }
  
  return {
    users: filteredUsers,
    loading,
    error,
    searchTerm,
    setSearchTerm,
    addUser,
    removeUser,
    updateUser
  }
}

export default useUsers

作成したカスタムフック使い方:

JSX
import useUsers from './hooks/useUsers'

function UserManagement() {
  const {
    users,
    loading,
    error,
    searchTerm,
    setSearchTerm,
    addUser,
    removeUser
  } = useUsers()
  
  if (loading) return <p>読み込み中...</p>
  if (error) return <p>エラー: {error}</p>
  
  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="ユーザーを検索"
      />
      
      <p>検索結果: {users.length}</p>
      
      <ul>
        {users.map(user => (
          <li key={user.id}>
            {user.name} - {user.email}
            <button onClick={() => removeUser(user.id)}>削除</button>
          </li>
        ))}
      </ul>
    </div>
  )
}

TypeScript

TSX
// hooks/useUsers.ts
import { useState, useEffect } from 'react'

// ↑ 型チェック①:APIのレスポンス型を明示
//   使う側はUserを見るだけでAPIのデータ構造がわかる
export interface User {
  id: number
  name: string
  email: string
  phone?: string
  website?: string
}

// ↑ 型チェック②:フックの戻り値の型を明示
//   使う側はUseUsersReturnを見るだけで
//   フックが何を返すか一目でわかる
interface UseUsersReturn {
  users: User[]
  loading: boolean
  error: string | null
  searchTerm: string
  setSearchTerm: (term: string) => void
  addUser: (user: Omit<User, 'id'>) => void
  //              ↑ 型チェック③:Omit<User, 'id'>で
  //                idを除いた型を要求する
  //                addUser時はidをフック側で生成するので
  //                外から渡す必要がないことを型で表現
  removeUser: (id: number) => void
  updateUser: (id: number, updatedUser: Partial<User>) => void
  //                                    ↑ 型チェック④:Partial<User>で
  //                                      全フィールドをオプショナルにする
  //                                      更新したいフィールドだけ渡せばOK
}

function useUsers(): UseUsersReturn {
  const [users, setUsers] = useState<User[]>([])
  const [loading, setLoading] = useState<boolean>(true)
  const [error, setError] = useState<string | null>(null)
  const [searchTerm, setSearchTerm] = useState<string>('')

  useEffect(() => {
    const fetchUsers = async (): Promise<void> => {
      try {
        setLoading(true)
        const response = await fetch(
          'https://jsonplaceholder.typicode.com/users'
        )

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`)
        }

        const data: User[] = await response.json()
        //           ↑ 型チェック⑤:レスポンスをUser[]として受け取る
        //             APIのレスポンスが型と食い違う場合は
        //             実行時エラーになるので注意
        setUsers(data)
      } catch (err) {
        setError(err instanceof Error ? err.message : '不明なエラー')
        //               ↑ 型チェック⑥:TSではcatchのerrはunknown型
        //                 instanceofでErrorと確定させてからmessageを使う
      } finally {
        setLoading(false)
      }
    }

    fetchUsers()
  }, [])

  const filteredUsers = users.filter((user: User) =>
    user.name.toLowerCase().includes(searchTerm.toLowerCase())
  )

  const addUser = (user: Omit<User, 'id'>): void => {
    //                    ↑ 型チェック⑦:idなしのUserを受け取る
    //                      { name: 123 } はエラーになる ✅
    setUsers((prev) => [...prev, { ...user, id: Date.now() }])
    //                                         ↑ idはフック側で生成
  }

  const removeUser = (id: number): void => {
    //                    ↑ 型チェック⑧:idはnumber型
    //                      文字列のidを渡すとエラーになる ✅
    setUsers((prev) => prev.filter((user) => user.id !== id))
  }

  const updateUser = (id: number, updatedUser: Partial<User>): void => {
    //                                          ↑ 型チェック⑨:Partial<User>で
    //                                            更新したいフィールドだけ渡せる
    //                                            { name: 'John' } だけでもOK
    //                                            存在しないフィールドはエラー ✅
    setUsers((prev) =>
      prev.map((user) => (user.id === id ? { ...user, ...updatedUser } : user))
    )
  }

  return {
    users: filteredUsers,
    loading,
    error,
    searchTerm,
    setSearchTerm,
    addUser,
    removeUser,
    updateUser,
  }
}

export default useUsers

作成したカスタムフック使い方:

TSX
import useUsers from '../hooks/useUsers'
import type { User } from '../hooks/useUsers'

function UserManagement() {
  const {
    users, // User[]
    loading, // boolean
    error, // string | null
    searchTerm, // string
    setSearchTerm,
    addUser,
    removeUser,
    updateUser,
  } = useUsers()
  //  ↑ 型チェック①:分割代入した各変数に型が確定している
  //    users.map()やerror.toUpperCase()など
  //    型に合ったメソッドだけ使える

  if (loading) return <p>読み込み中...</p>
  if (error) return <p>エラー: {error}</p>
  if (!users) return null

  const handleAdd = (): void => {
    addUser({
      name: '新しいユーザー',
      email: 'new@example.com',
      //  ↑ 型チェック②:Omit<User, 'id'>で守られている
      //    idを渡すとエラー ✅
      //    nameをnumberにするとエラー ✅
    })
  }

  const handleUpdate = (id: number): void => {
    updateUser(id, {
      name: '更新後の名前',
      //  ↑ 型チェック③:Partial<User>で守られている
      //    更新したいフィールドだけ渡せばOK
      //    存在しないフィールドを渡すとエラー ✅
    })
  }

  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
          setSearchTerm(e.target.value)
        }
        //  ↑ 型チェック④:setSearchTermはstring型のみ受け付ける
        //    e.target.valueはstring型なので一致 ✅
        placeholder="ユーザーを検索"
      />

      <p>検索結果: {users.length}</p>

      <button onClick={handleAdd}>ユーザー追加</button>

      <ul>
        {users.map((user: User) => (
          //          ↑ 型チェック⑤:userはUser型と確定
          //            user.nameやuser.emailが補完される
          //            存在しないuser.ageはエラー ✅
          <li key={user.id}>
            {user.name} - {user.email}
            <button onClick={() => removeUser(user.id)}>
              {/*                          ↑ 型チェック⑥:user.idはnumber型 */}
              {/*                            removeUserはnumberを要求しているので一致 ✅ */}
              削除
            </button>
            <button onClick={() => handleUpdate(user.id)}>編集</button>
          </li>
        ))}
      </ul>
    </div>
  )
}

export default UserManagement


JS版との主な違いをまとめると
  • User interface → APIのレスポンス構造を型で表現
  • UseUsersReturn → フックの戻り値を型で文書化
  • Omit → addUser時にidを渡せないことを型で強制
  • Partial → updateUser時に一部フィールドだけ渡せることを型で表現
  • string | null → errorがnullの可能性を型で明示

まとめ

この記事では、カスタムフックの作り方と活用方法を詳しく学びました。

重要なポイント:

  • カスタムフックはロジックを再利用する仕組み
  • 関数名は必ずuseで始める
  • 他のHooksを組み合わせて作る
  • JSXではなく、状態やロジックを返す
  • 複数のコンポーネントで同じロジックを共有できる

よく使われるカスタムフックのパターン:

  • useFetch: データ取得
  • useLocalStorage: 永続化
  • useForm: フォーム管理
  • useDebounce: 入力遅延
  • useToggle: 真偽値切り替え
  • useWindowSize: ウィンドウサイズ

ベストプラクティス:

  • 単一責任の原則を守る
  • 適切な命名規則を使う
  • 戻り値の形式を統一する
  • TypeScriptで型を明示する
  • 再利用可能なロジックだけを抽出する

次のステップ: 次回は、React Routerを使ったページ遷移について学びます。カスタムフックとルーティングを組み合わせることで、より実践的なアプリケーションが作れるようになります!

カスタムフックはReactの真髄です。様々なパターンを実装して、再利用可能なコードを書く技術を磨いていきましょう!