カスタムフックでロジックを切り出すときのメモ
カスタムフックでロジックを切り出すときのメモ
コンポーネントに useState と useEffect がどんどん増えて、「結局このコンポーネント何を表示してるんだっけ」状態になったので、整理がてら書いておくメモです。
こういう 状態を伴うロジック は、カスタムフックに切り出すと再利用できて、コンポーネントもすっきりします。作り方と設計の勘所を整理します。
カスタムフックは「ロジックの再利用」の単位
カスタムフックとは、use で始まる名前を持って、内部で他のフックを呼ぶただの関数です。コンポーネントとの違いは JSXを返さず、値やロジックを返す ことです。状態を持ったロジックを、見た目から切り離して持ち運べるようにする仕組み、という理解でいます。
例として、ウィンドウ幅を購読する処理を切り出してみます。コンポーネントに直接書くと、リスナー登録とクリーンアップでかさばります。こんな感じです。
function useWindowWidth() {
// SSR(サーバー)ではwindowが存在しないため、初期値はnullにして
// ブラウザでマウントされたあとにeffectで実値を入れる
const [width, setWidth] = useState<number | null>(null)
useEffect(() => {
const onResize = () => setWidth(window.innerWidth)
onResize() // マウント時点の幅を反映
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
return width
}
初期値を window.innerWidth にしたくなるのですが、window はサーバー側に存在しないので、Next.jsのようにSSRする環境ではそのまま参照すると ビルドや初期描画で落ちます。自分はこれで一度ハマりました。初期値を null にしておいて、ブラウザでマウントされてから useEffect で実値を入れると、サーバーとクライアントの初期描画も一致してハイドレーションのズレを避けられます。
使う側はたった1行になります。
function Layout() {
const width = useWindowWidth()
if (width === null) return null // マウント前は描画しない
return width < 768 ? <MobileNav /> : <DesktopNav />
}
リスナーの後始末やSSRへの配慮は useWindowWidth の中に閉じ込められて、コンポーネントは「幅で出し分ける」という意図だけを表しています。すっきりしました。
ここは誤解しやすい:状態は「複製」される
一つ誤解しやすい点があります。カスタムフックは ロジックを共有しますが、状態は共有しません。同じフックを2つのコンポーネントで呼べば、それぞれ独立したstateを持ちます。
function A() {
const width = useWindowWidth() // Aのstate
}
function B() {
const width = useWindowWidth() // Bの別のstate
}
「複数コンポーネントで同じ値を共有したい」なら、それはカスタムフックではなくContextやストアの役割です。ここを混同するとバグになるので注意してください。たぶん最初は一度やらかすところだと思います。
何を切り出すべきか
切り出しの判断は「再利用するかどうか」だけではありません。次のどれかに当てはまるなら、切り出す価値があります。
| サイン | 例 |
|---|---|
| 同じstate+effectの塊が複数箇所にある | データ取得、フォーム制御 |
| コンポーネントが長く読みにくい | UIとロジックが混在 |
| ロジックを単体でテストしたい | 計算や購読を切り離す |
逆に、一度しか使わない短いロジックを無理に切り出すと、かえって追いづらくなります。「意味のあるまとまりか」を基準にするとよさそうです。
入出力を素直に設計する
使いやすいカスタムフックは、引数と戻り値が素直です。たとえば「真偽値をトグルする」フックは、現在値と操作関数をまとめて返すと扱いやすくなります。
function useToggle(initial = false) {
const [on, setOn] = useState(initial)
const toggle = useCallback(() => setOn((v) => !v), [])
const set = useCallback((next: boolean) => setOn(next), [])
return { on, toggle, set }
}
const { on, toggle } = useToggle()
return <button onClick={toggle}>{on ? '開' : '閉'}</button>
戻り値が複数あって順番に意味がないなら、配列よりオブジェクトのほうが「どれを使うか」を選べて読みやすいです。
命名のルール
カスタムフックは必ず use で始めます。これは規約であると同時に、Reactのフックのルール(条件分岐やループの中で呼ばない等)を リンタが検査するための目印 でもあるようです。use で始まらない関数の中でフックを呼ぶと、eslint-plugin-react-hooks が見落としてしまいます。
名前は「何を提供するか」を表すものにします。useUser、useDebouncedValue、useLocalStorage のように、返すものや扱う対象がわかる名前が理想です。
まとめ
- カスタムフックは「JSXを返さず、状態を伴うロジックを返す」関数
- 同じ呼び出しでも状態は複製される。共有したいならContext/ストアを使う
- 切り出す基準は「意味のあるまとまりか」。引数・戻り値は素直に設計する
- 名前は必ず
use始まり。リンタのフック検査もこれを目印にしている
設計の指針はReact公式のReusing Logic with Custom Hooksにも整理されています。まずは既存コンポーネントの中で「state+effectの塊」を一つ探して、切り出してみるところから始めるとつかみやすいと思います。以上です。