カスタムフックとロジックの再利用
カスタムフックとは
一言でいうと、「自分で作る useState / useEffect 的な関数」です。
条件はこれだけ:
- 関数名が
useから始まる - 中で
useStateやuseEffectなど 他のフックを使う
例:
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue)
const toggle = () => setValue((v) => !v)
return [value, toggle]
}これも立派なフックです。
使う側は、React標準フックと同じノリで使えます。
const [isOpen, toggleOpen] = useToggle(false)カスタムフックの目的はざっくり2つ:
- ロジックをコンポーネントから切り離して見通しを良くする
- 同じパターンのロジックを複数コンポーネントで再利用する
シンプルな例:useToggle
まずは感覚をつかむために、さっきの useToggle をちゃんと書いてみます。
// useToggle.js
import { useState } from 'react'
export function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue)
const toggle = () => {
setValue((prev) => !prev)
}
return [value, toggle]
}使い方:
// App.jsx
import { useToggle } from './useToggle'
export default function App() {
const [isOpen, toggleOpen] = useToggle(false)
const [isOn, toggleOn] = useToggle(true)
return (
<div>
<button onClick={toggleOpen}>
詳細を {isOpen ? '閉じる' : '開く'}
</button>
{isOpen && <p>ここに詳細テキスト</p>}
<hr />
<button onClick={toggleOn}>
電源: {isOn ? 'ON' : 'OFF'}
</button>
</div>
)
}ここでやっていること:
useState+setState((prev)=>!prev)というよくあるパターンを1個にまとめた- その結果、
App側は 「useToggleを2回呼ぶ」だけで同じロジックが使える
「重複したロジック」を見つけて切り出す
カスタムフックの作り方の実務的な流れは:
- まずは普通にコンポーネントを書く
- 「あれ、このロジック、別コンポーネントでもほぼ同じもの書いてるな…」 と気づく
- その部分をカスタムフックに抽出する
という順番です。
Before:2つのカウンターコンポーネント
// CounterA.jsx
import { useState } from 'react'
export function CounterA() {
const [count, setCount] = useState(0)
return (
<div>
<p>Counter A: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
<button onClick={() => setCount(0)}>リセット</button>
</div>
)
}
// CounterB.jsx
import { useState } from 'react'
export function CounterB() {
const [count, setCount] = useState(10)
return (
<div>
<p>Counter B: {count}</p>
<button onClick={() => setCount((c) => c - 1)}>-1</button>
<button onClick={() => setCount(10)}>リセット</button>
</div>
)
}両方とも、「count を持って、ボタンで増減・リセット」しているだけで、ロジックがほぼ同じです。
After:useCounter に抽出
// useCounter.js
import { useState } from 'react'
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = () => setCount((c) => c + 1)
const decrement = () => setCount((c) => c - 1)
const reset = () => setCount(initialValue)
return { count, increment, decrement, reset }
}使う側:
// CounterA.jsx
import { useCounter } from './useCounter'
export function CounterA() {
const { count, increment, reset } = useCounter(0)
return (
<div>
<p>Counter A: {count}</p>
<button onClick={increment}>+1</button>
<button onClick={reset}>リセット</button>
</div>
)
}
// CounterB.jsx
import { useCounter } from './useCounter'
export function CounterB() {
const { count, decrement, reset } = useCounter(10)
return (
<div>
<p>Counter B: {count}</p>
<button onClick={decrement}>-1</button>
<button onClick={reset}>リセット</button>
</div>
)
}useCounter に「カウンターの挙動」を閉じ込めたので、コンポーネント側は「表示」と「どの操作をUIに紐づけるか」だけ書けばよくなります。
こういう「ロジック = フック / UI = コンポーネント」の分離が、カスタムフックの一番良いところです。
データ取得用フック:useFetch
前回、解説した「API通信+loading+error」は、いろんな画面で頻出するパターンです。
これもカスタムフック化するとかなりスッキリします。
useFetch(url) の例
// useFetch.js
import { useState, useEffect } from 'react'
export function useFetch(url) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
if (!url) return
let isCancelled = false
async function fetchData() {
try {
setLoading(true)
setError(null)
const res = await fetch(url)
if (!res.ok) throw new Error('ネットワークエラー')
const json = await res.json()
if (!isCancelled) {
setData(json)
}
} catch (err) {
if (!isCancelled) {
setError(err.message || 'エラーが発生しました')
}
} finally {
if (!isCancelled) {
setLoading(false)
}
}
}
fetchData()
return () => {
isCancelled = true
}
}, [url])
return { data, loading, error }
}使う側:
// PostList.jsx
import { useFetch } from './useFetch'
export default function PostList() {
const { data: posts, loading, error } = useFetch(
'https://jsonplaceholder.typicode.com/posts?_limit=5',
)
if (loading) return <p>読み込み中...</p>
if (error) return <p style={{ color: 'red' }}>エラー: {error}</p>
if (!posts) return null
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
)
}同じフックを別コンポーネントでも使える:
// UserList.jsx
import { useFetch } from './useFetch'
export default function UserList() {
const { data: users, loading, error } = useFetch(
'https://jsonplaceholder.typicode.com/users',
)
// 以下同様
}こうしておくと、API通信・エラーハンドリングまわりのロジックは useFetch に閉じ込め、各コンポーネントは「データの表示」に集中できるようになります。
※ 実務では更に高度なことが必要になるので、React Query や SWR などのライブラリを使うことが多いですが、
カスタムフックでこのくらい書けるようになっておくと、それらのライブラリも理解しやすいです。
フォーム用フック:useForm の例
共通処理:
- 値をstateで持つ
onChangeでe.target.name/e.target.valueを拾う- 送信時に
preventDefaultする - リセットする
これもある程度まとめられます。
シンプルな useForm
// useForm.js
import { useState } from 'react'
export function useForm(initialValues) {
const [values, setValues] = useState(initialValues)
function handleChange(e) {
const { name, type, value, checked } = e.target
setValues((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}))
}
function reset() {
setValues(initialValues)
}
return { values, handleChange, reset, setValues }
}使う側:
// ContactForm.jsx
import { useForm } from './useForm'
export default function ContactForm() {
const { values, handleChange, reset } = useForm({
name: '',
email: '',
message: '',
subscribe: false,
})
function handleSubmit(e) {
e.preventDefault()
console.log('送信:', values)
reset()
}
return (
<form onSubmit={handleSubmit}>
<div>
<label>
名前:
<input
name="name"
value={values.name}
onChange={handleChange}
/>
</label>
</div>
<div>
<label>
メール:
<input
name="email"
value={values.email}
onChange={handleChange}
/>
</label>
</div>
<div>
<label>
メッセージ:
<textarea
name="message"
value={values.message}
onChange={handleChange}
/>
</label>
</div>
<div>
<label>
<input
name="subscribe"
type="checkbox"
checked={values.subscribe}
onChange={handleChange}
/>
メルマガ購読
</label>
</div>
<button type="submit">送信</button>
<button type="button" onClick={reset}>リセット</button>
</form>
)
}こうしておけば、別のフォームでも同じ useForm を使い回せます。
フックは「組み合わせて」使える
カスタムフックの強みは、「フック同士をネストできる」ことです。
例:
function useExample() {
const [count, setCount] = useState(0)
const [isOpen, toggleOpen] = useToggle(false)
const { data, loading } = useFetch('/api/items?count=' + count)
// ここにさらに独自ロジック
return { count, setCount, isOpen, toggleOpen, data, loading }
}- 自作フックの中で他の自作フックを呼んでOK
- それぞれの責務を分けておけば、「ロジックのレゴブロック」みたいに組める
最終的には、以下の分業を目指す形がよいです。
- コンポーネント:UI(JSX)だけに近くなる
- カスタムフック:状態管理・API呼び出し・バリデーションなどのロジックを担当
カスタムフックにも「フックのルール」がそのまま適用される
標準フックと同じく、カスタムフックも以下のルールを守る必要があります:
useから始まる名前にする- Reactが「これはフックだ」と判定するのに必要
- ESLint の
react-hooksルールもこの前提
- トップレベルでしか呼ばない
- 条件分岐の中で呼ばない
- ループの中で呼ばない
- 早期returnの前後で呼ぶ数を変えない
// NG: if (flag) { const [x, setX] = useState(0) }// OK:const [x, setX] = useState(0) if (!flag) return null - フックはコンポーネント or カスタムフックの中でだけ使う
- 普通の関数の中では
useStateなどを使えない - カスタムフック自体は「フックを呼ぶためのラッパ」なのでOK
- 普通の関数の中では
実質的には、「フックは常に同じ順番・同じ個数で呼ばれるように書く」ということです。
まとめ
- カスタムフックは「
useから始まり、中で他のフックを使う関数」 - 目的は
- ロジックをコンポーネントから切り離して見通しを良くする
- 複数コンポーネントでロジックを再利用する
- 典型例:
useToggle(真偽値トグル)useCounter(汎用カウンター)useFetch(API通信+loading+error)useForm(フォーム値とハンドラ)
- フックのルール(トップレベルで呼ぶ、
useから始める)はカスタムフックにもそのまま適用される - 最終的には
- UI = コンポーネント
- ロジック = カスタムフック
という分離を意識すると、大きめのアプリでも整理しやすくなる
コメント