フロントエンド

カスタムフックでロジックを切り出すときのメモ

読了 7分
フロントエンド

カスタムフックでロジックを切り出すときのメモ

コンポーネントに useStateuseEffect がどんどん増えて、「結局このコンポーネント何を表示してるんだっけ」状態になったので、整理がてら書いておくメモです。

こういう 状態を伴うロジック は、カスタムフックに切り出すと再利用できて、コンポーネントもすっきりします。作り方と設計の勘所を整理します。

カスタムフックは「ロジックの再利用」の単位

カスタムフックとは、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 が見落としてしまいます。

名前は「何を提供するか」を表すものにします。useUseruseDebouncedValueuseLocalStorage のように、返すものや扱う対象がわかる名前が理想です。

まとめ

  • カスタムフックは「JSXを返さず、状態を伴うロジックを返す」関数
  • 同じ呼び出しでも状態は複製される。共有したいならContext/ストアを使う
  • 切り出す基準は「意味のあるまとまりか」。引数・戻り値は素直に設計する
  • 名前は必ず use 始まり。リンタのフック検査もこれを目印にしている

設計の指針はReact公式のReusing Logic with Custom Hooksにも整理されています。まずは既存コンポーネントの中で「state+effectの塊」を一つ探して、切り出してみるところから始めるとつかみやすいと思います。以上です。