React

Reactカスタムフックのベストプラクティス|命名・引数・返り値・データ取得の判断基準【React 19.3】

Reactカスタムフックのベストプラクティス|命名・引数・返り値・データ取得の判断基準【React 19.3】

カスタムフックは「useで始まる関数にロジックを切り出すもの」と説明されますが、実務で迷うのはその先です。引数のオプションに既定値{}を置いてよいのか、返り値は配列とオブジェクトのどちらにするのか、データ取得を自作してよいのか、React Compilerを入れたらuseCallbackは消してよいのか。この記事では、React 19.3.1・ESLint 10.14.0(eslint-plugin-react-hooks 7.1.1)・React Compiler 1.0で掲載コードをすべて実行し、その結果から判断基準を示します。実行環境はmacOS上のNode.js 26.5.0、確認日は2026年11月3日です。

まとめ:カスタムフック設計の判断基準

論点 結論 実測の根拠
命名 フックを呼ぶ関数だけuseを付ける 違反してもlintは無警告
引数 オプションは分割代入し使う値を依存配列へ 既定値{}はEffect6回
返り値(配列) as constか型注釈でタプルにする 推論のままだとTS2349
返り値(オブジェクト) 依存配列に入る用途なら参照を固定 Effect 6回対1回
フック内のコンポーネント定義 フックの外で定義する 子が毎回再マウント
Effectラッパー additionalEffectHooksを設定 既定では依存配列が未検査
データ取得 自作するならクリーンアップ必須 無いと古い応答が残る
手書きメモ化 新規は省き既存は残すか検証して外す 検証例で変換結果が一致

React Compilerを入れても直らない書き方が2つあります。引数の既定値{}と、フックの中でのコンポーネント定義です。どちらもeslint-plugin-react-hooks 7.1.1の全29ルールを有効にしても警告が出なかったため、コードレビューやテストで止める必要があります。

カスタムフックの定義と共有される範囲

標準フックとの違いと状態の独立性

カスタムフックは、useStateやuseEffectなどの標準フックを内部で呼び出す、use接頭辞の付いた自作関数です。標準フックはReact本体が提供し、カスタムフックはアプリケーション側で定義します。標準フック18種それぞれの役割はReact Hooks(フック)とは?全18種の一覧と使い方・ルール【React 19.3対応】で一覧にしています。

設計の前提として押さえるべきなのは、共有されるのがロジックであって状態ではない点です。react.devの「Reusing Logic with Custom Hooks」は「Custom Hooks let you share stateful logic but not state itself.」と書いています。同じフックを2つのコンポーネントで呼んでも、値は混ざりません。

function useCounterValue(start) {
  const [n] = useState(start);
  return n;
}
function A() { return <span>A={useCounterValue(1)}</span>; }
function B() { return <span>B={useCounterValue(100)}</span>; }

// renderToStaticMarkup(<div><A /><B /></div>) の出力(React 19.3.1)
// <div><span>A=1</span><span>B=100</span></div>

状態そのものを複数のコンポーネントで共有したいなら、カスタムフックではなく状態のリフトアップかContextを使います。Contextの実装と再レンダー対策はReact Contextの使い方|createContext・useContext・Providerの実装と再レンダー対策【React 19.3対応】で扱っています。

切り出す単位と切り出さない単位

切り出しは用途単位で行います。react.devが良い例として挙げるのはuseData(url)、useChatRoom(options)、useSocket(url)のように、名前を見れば何をするかが分かるフックです。名前が決まらないときは、Effectが周囲のコードと絡みすぎているサインだとも書かれています。

避けるべきなのは、useEffectの薄いラッパーとして作るライフサイクル模倣フックです。公式はuseMount(fn)・useEffectOnce(fn)・useUpdateEffect(fn)を名指しし、「Avoid creating and using custom “lifecycle” Hooks that act as alternatives and convenience wrappers for the useEffect API itself」としています。マウント時だけ動かしたい処理を包むと、依存配列の検査から外れて古い値を読み続けるためです。

フックを1つも呼ばない処理も切り出し対象外です。配列をソートするだけの関数にuseを付けると、条件分岐の中で呼べなくなる制約だけが残ります。逆に、DOMに触れずフックだけで組んだロジックはReact Nativeとも共有できます。Webとアプリで同じ状態管理を使い回す構成は、Flutter / React Nativeのクロスプラットフォーム開発でも採る設計です。

命名規則:use接頭辞の条件とeslint-plugin-react-hooksが見ていない範囲

use接頭辞を付ける条件

フック名はuseに続けて大文字で始めます。この命名はReactとリンタが「この関数はフックである」と判別する手掛かりで、見た目の統一のためだけのものではありません。内部でフックを呼ばない関数にはuseを付けず、react.devはソート関数をuseSortedではなくgetSortedと名付ける例を示しています。後述するとおり、React Compilerもこの名前でフックかどうかを判定します。

リンタが検出する違反と素通しする違反

命名の違反がすべてリンタで止まるわけではありません。eslint-plugin-react-hooks 7.1.1(2026年4月17日公開)をESLint 10.14.0で実行した結果の抜粋です。

// (1) 通常関数からフックを呼ぶ
export function loadUser(id) {
  const [user, setUser] = useState(null);
  return user;
}
// error  React Hook "useState" is called in function "loadUser" that is
//        neither a React function component nor a custom React Hook function.
//        react-hooks/rules-of-hooks

// (2) 条件分岐の中でカスタムフックを呼ぶ
export function Panel({ enabled }) {
  if (enabled) {
    const online = useOnlineStatus();
    return <span>{String(online)}</span>;
  }
  return null;
}
// error  React Hook "useOnlineStatus" is called conditionally.
//        react-hooks/rules-of-hooks

// (3) フックを呼ばないのに use 接頭辞
export function useSorted(items) {
  return items.slice().sort();
}
// 警告なし

(1)と(2)はerrorで止まり、(3)は無言で通ります。公式が避けるよう求める命名でも、リンタは教えてくれません。プラグインに含まれる全29ルールを有効にしても結果は同じでした。この種の規約違反はレビューか、React Doctorとは?使い方・スコアの仕組み・CI設定とESLintとの違い【0.9.18対応】で紹介している静的診断ツールで拾います。

引数の設計:オプションオブジェクトの既定値が招く再実行

引数が増えたら、位置引数を並べるよりオプションオブジェクトにまとめる方が呼び出し側は読みやすくなります。ただし、受け取ったオブジェクトをそのまま依存配列に入れると、呼び出し側がレンダーのたびに新しいオブジェクトを渡す場合に毎回再実行されます。依存配列は前回の値とObject.isで比べるため、中身が同じでも参照が変われば別の値とみなされます。親を5回再レンダーし、Effectの実行回数を数えました。

// A: オブジェクトごと依存配列へ
function usePolling(url, options = {}) {
  useEffect(() => {
    // options.interval を使ってポーリングを開始する
  }, [url, options]);
}

// B: 分割代入して原始値だけを依存配列へ
function usePolling(url, { interval = 1000 } = {}) {
  useEffect(() => {
    // interval を使ってポーリングを開始する
  }, [url, interval]);
}
呼び出し方 Compilerなし Compilerあり
A:usePolling(‘/a’)(既定値{}) 6回 6回
A:usePolling(‘/a’, { interval: 500 }) 6回 1回
B:分割代入版 1回 1回

6回は初回1回と再レンダー5回の合計で、毎回やり直しています(StrictModeを使わない計測。開発時のStrictModeではマウント直後にEffectがもう1回実行されます)。注目すべきは既定値{}の行です。呼び出し側のインラインオブジェクトはReact Compilerが固定してくれますが、引数の既定値はフックが呼ばれるたびに新しいオブジェクトとして作られるため、Compilerを入れても6回のままでした。オプションは分割代入で受け取り、Effectが実際に使う値を依存配列に入れる。この例ならCompilerの有無に左右されません。依存配列には使う値をすべて入れるのが原則で、オブジェクトや関数を黙って外すと古い値を読む不具合になります。外すのではなく、中身の値に分けて渡します。

既定値は「指定しなくても動く値」にしておきます。必須の値まで既定値で埋めると、呼び出し側の書き忘れが黙って通ります。

返り値の設計:配列・オブジェクトとコンポーネント定義の注意点

配列返却時のタプル型の確定

useStateのように[値, 更新関数]で返す場合、TypeScriptでは返り値をタプル型として確定させる必要があります。戻り値の型注釈もas constも無いと、配列はユニオン型の配列として推論され、呼び出し側で型エラーになります。TypeScript 7.0.2と@types/react 19.3.0でTSXファイルを検査した結果です。

import { useState } from 'react';

function useToggleA(init: boolean) {
  const [on, setOn] = useState(init);
  return [on, () => setOn(v => !v)];          // 推論: (boolean | (() => void))[]
}
function useToggleB(init: boolean) {
  const [on, setOn] = useState(init);
  return [on, () => setOn(v => !v)] as const; // 推論: readonly [boolean, () => void]
}
function useToggleC(init: boolean): [boolean, () => void] {
  const [on, setOn] = useState(init);
  return [on, () => setOn(v => !v)];          // 戻り値の型注釈でタプルにする
}

export function P() {
  const [a, toggleA] = useToggleA(false);
  return <button onClick={() => toggleA()}>{String(a)}</button>;
}
// error TS2349: This expression is not callable.
//   Not all constituents of type 'boolean | (() => void)' are callable.
//     Type 'false' has no call signatures.

export function Q() {
  const [b, toggleB] = useToggleB(false);
  return <button onClick={() => toggleB()}>{String(b)}</button>;
}
export function R() {
  const [c, toggleC] = useToggleC(false);
  return <button onClick={() => toggleC()}>{String(c)}</button>;
}
// Q と R はエラーなし

as constと戻り値の型注釈のどちらでも呼び出し側は通ります。配列は呼び出し側で自由に名前を付けられるのが利点ですが、要素が増えるほど位置を覚える負担が増します。この記事では、返す値が2つまでなら配列、3つ以上ならオブジェクトを目安にしています。

オブジェクト返却時の参照の安定性

オブジェクトを毎レンダー作り直すと、呼び出し側がそれを依存配列に入れた時点でEffectが毎回再実行されます。親を5回再レンダーしたときの実行回数です。

// A: 毎回新しいオブジェクトを返す
function useApiA(url) {
  const [n, setN] = useState(0);
  return { url, count: n, reload: () => setN(n + 1) };
}
// B: 返り値を固定する
function useApiB(url) {
  const [n, setN] = useState(0);
  const reload = useCallback(() => setN(v => v + 1), []);
  return useMemo(() => ({ url, count: n, reload }), [url, n, reload]);
}

function make(useApi) {
  return function Screen({ tick }) {
    const api = useApi('/items');
    useEffect(() => { effectRuns++; }, [api]);
    return <span>{tick}</span>;
  };
}
// make(useApiA) の useEffect 実行回数 = 6
// make(useApiB) の useEffect 実行回数 = 1

呼び出し側でも、オブジェクト全体ではなくapi.countのように使う値だけを依存配列に入れれば再実行は避けられます。返り値が依存配列やmemo化した子のpropsに渡る使い方を想定するフックなら、提供する側で固定しておくと呼び出し側の負担が減ります。React Compilerがこのフックを変換できた場合は、この固定が自動で入ります(後述)。

フック内でのコンポーネント定義による再マウント

モーダルやダイアログの開閉をフックにまとめるとき、フックの中でコンポーネントを定義して返す書き方を見かけます。これは子の状態を壊します。フックの中で定義した関数は呼ばれるたびに別物になり、Reactはそれを別の型のコンポーネントとみなして作り直すためです。中に状態を持つ子を置き、マウント回数を数えました。

// 避ける: フックの中でコンポーネントを定義して返す
function useDialog() {
  const [open, setOpen] = useState(false);
  function Dialog({ children }) {
    return <div>{open ? children : null}<Inner /></div>;
  }
  return { Dialog, setOpen };
}

// 推奨: フックは状態だけを返し、コンポーネントは外で定義する
function useDialogState() {
  const [open, setOpen] = useState(false);
  return { open, setOpen };
}
function Dialog({ open, children }) {
  return <div>{open ? children : null}<Inner /></div>;
}

// 親を5回再レンダーしたときの Inner のマウント回数
//   フック内で定義 : 6(毎回作り直され、Inner の state が消える)
//   外で定義       : 1

React Compilerを通すと、フック内のDialogはopenが変わったときだけ作り直されるようになり、再レンダー5回ではマウント1回に収まりました。それでも開閉を2回切り替えると3回にマウントが増え、開閉のたびに子の入力内容が消えます。eslint-plugin-react-hooks 7.1.1は、全29ルールを有効にしてもこの書き方を検出しませんでした。問題はJSXを返すこと自体ではなく、呼ばれるたびに新しいコンポーネント関数を作る点にあります。フックはデータと操作を返し、見た目はフックの外で定義したコンポーネントに任せる、と分けておくのが安全です。

Effectを内包するカスタムフックの依存配列

自作Effectラッパーの依存配列検査とadditionalEffectHooks

useEffectやuseLayoutEffectを包むカスタムフックを作り、依存配列を引数でそのまま渡す設計はよくあります。この場合、既定の設定ではexhaustive-depsが依存配列を検査しません。exhaustive-depsの公式リファレンスによると、6.1.1以降はsettingsのadditionalEffectHooksに正規表現を書いて検査対象に加えます。

export function useIsomorphicLayoutEffect(fn, deps) {
  useLayoutEffect(fn, deps);
}
export function Title({ text }) {
  useIsomorphicLayoutEffect(() => {
    document.title = text;   // text が依存配列に無い
  }, []);
  return <h1>{text}</h1>;
}
// eslint.config.mjs
import reactHooks from 'eslint-plugin-react-hooks';
export default [
  {
    files: ['**/*.{js,jsx}'],
    ...reactHooks.configs.flat.recommended,
    languageOptions: {
      parserOptions: {
        ecmaVersion: 'latest',
        sourceType: 'module',
        ecmaFeatures: { jsx: true },
      },
    },
    settings: {
      'react-hooks': { additionalEffectHooks: '^useIsomorphicLayoutEffect$' },
    },
  },
];

// 命名章の3例と同じファイルをまとめて検査した結果
// settings あり : 4 problems (2 errors, 2 warnings)
//   warning  React Hook useIsomorphicLayoutEffect has a missing dependency: 'text'
// settings なし : 3 problems (2 errors, 1 warning)  ← 上記の警告が出ない
// 2 errors は命名章の(1)(2)。残る1 warning はラッパー内部の
// useLayoutEffect(fn, deps) に対する「dependencies are unknown」

自作のEffectラッパーを持っているなら、settingsのadditionalEffectHooksか、exhaustive-depsのルール単位オプションadditionalHooksのどちらかを入れない限り、その依存配列は検査されません。公式リファレンスによると、settings側はすべてのreact-hooksルールで共有され、ルール単位の指定はsettingsより優先されます。languageOptionsを省くとJSXがパースできず検査自体が走らない点にも注意してください。プリセットはパーサー設定を含みません。なおconfigs.flat['recommended-latest']も残っており、recommendedの16ルールにvoid-use-memoを加えた17ルール構成です。

受信時コールバックと再接続条件の分離

カスタムフックがコールバックを受け取り、それをEffectの依存配列に入れると、呼び出し側が毎回新しい関数を渡すたびに接続がやり直されます。メッセージ受信時の処理はEffectから発火するイベントであり、接続をやり直す理由ではありません。React 19.2で安定版になったuseEffectEventで包むと、再接続の条件をroomIdだけに絞れます。

// 素直に依存配列へ入れた場合
function useChatRoomNaive({ roomId, onReceive }) {
  useEffect(() => {
    const c = createConnection(roomId);
    c.on('message', onReceive);
    return () => c.disconnect();
  }, [roomId, onReceive]);   // onReceive が毎レンダー変わる
}

// Effect Event に切り出した場合
function useChatRoom({ roomId, onReceive }) {
  const onMessage = useEffectEvent(onReceive);
  useEffect(() => {
    const c = createConnection(roomId);
    c.on('message', (m) => onMessage(m));
    return () => c.disconnect();
  }, [roomId]);
}

// 親を5回再レンダーしたときの接続回数(React 19.3.1)
//   useEffectEvent なし : 6
//   useEffectEvent あり : 1

ただしuseEffectEventのリファレンスは「Do not use useEffectEvent to avoid specifying dependencies in your Effect’s dependency array.」と、依存配列を短くする目的での使用を戒めています。roomIdのように、変わったら接続し直すべき値はこれまでどおり依存配列に入れます。Effect EventはEffectの中か他のEffect Eventからしか呼べず、レンダー中に呼んだり他のフックへ渡したりはできません。識別子は意図的に毎レンダー変わるので、依存配列にも入れません。また19.2系には、memo()やforwardRefで包んだコンポーネントでuseEffectEventが古い値を読む不具合があり、2026年9月9日公開のReact 19.3.0で修正されました(#34831)。useEffectEventを含むカスタムフックをmemo化した部品で使うなら19.3系へ上げてください。19.2の変更点はReact 19.2の新機能|Activity・useEffectEvent・cacheSignal・PPRの使い方と19.2.8までの修正にまとめています。

データ取得フックの競合状態とライブラリへの切り替え

クリーンアップ不在時の古い応答による上書き

データ取得はカスタムフックの代表例ですが、クリーンアップを書かずに自作すると競合状態を作り込みます。URLを切り替えた直後に、先に投げた遅いリクエストが後から返ると、新しい結果を上書きするからです。/u/1を300ミリ秒、/u/2を50ミリ秒で返す疑似fetchを用意し、1から2へ切り替えた後の表示を確かめました。

// 対策なし
function useUser(url) {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetchJson(url).then(setData);
  }, [url]);
  return data;
}
// 最終的な data = '/u/1' の結果(古い応答が勝つ)

// ignore フラグ(react.dev の例と同じ形)
useEffect(() => {
  let ignore = false;
  fetchJson(url).then(v => { if (!ignore) setData(v); });
  return () => { ignore = true; };
}, [url]);
// 最終的な data = '/u/2' の結果

// AbortController(通信そのものを止める)
useEffect(() => {
  const controller = new AbortController();
  fetchJson(url, { signal: controller.signal })
    .then(setData)
    .catch(e => { if (e.name !== 'AbortError') throw e; });
  return () => controller.abort();
}, [url]);
// 最終的な data = '/u/2' の結果、通信中に中断されたリクエスト = 1件(/u/1)

react.devの「You Might Not Need an Effect」は、この現象を「race condition」と呼び、クリーンアップで古い応答を無視するignoreフラグの形を示しています。ignoreは結果を捨てるだけで通信は続くので、帯域やサーバー負荷まで抑えたいならAbortControllerを使います。

キャッシュと再取得が必要な場合のライブラリ選択

競合状態を直しても、自作フックにはキャッシュ・重複リクエストの統合・画面復帰時の再取得がありません。React公式のuseEffectリファレンスも、フレームワークの組み込み機構か、クライアント側キャッシュを提供するTanStack Query・useSWR・React Router 6.4以降の利用を勧めています。判断の目安は次のとおりです。

  • 1画面で1回読むだけ、キャッシュ不要:上のAbortController版を自作フックにする
  • 複数の画面で同じデータを読む、更新後に再取得したい:TanStack Query(v5系の最新は2026年11月3日時点で5.105.3)かSWRを使う
  • Next.jsなどのフレームワーク上:まずフレームワークのデータ取得機構を使う

ライブラリを入れた場合も、useQueryをそのまま各画面で呼ぶより、useUser(id)のような用途名のカスタムフックで包むと、キャッシュキーや取得関数の重複を1か所にまとめられます。TanStack Queryの既定値の落とし穴はTanStack Queryとは?React Queryとの違い・v5の使い方と既定値の落とし穴、SWRのカスタムフック化はuseSWRとは?使い方とfetcher・mutate・オプション既定値を解説【SWR 2.5対応】で扱っています。

React Compiler 1.0導入下で手書きメモ化を残すかの判断

検証例での手書きメモ化の有無と変換結果

競合記事の多くは「返す関数はuseCallbackで包む」と書いています。React Compilerがそのフックを変換できる場合、新しく書くコードではこの手当ては基本的に要りません。babel-plugin-react-compiler 1.0.0で、手書きメモ化のある版とない版を変換して比べました。

// 手書きメモ化なし
export function useFilteredItems(items, query) {
  const [sel, setSel] = useState(null);
  const filtered = items.filter(i => i.includes(query));
  const onPick = (i) => setSel(i);
  return { filtered, onPick, sel };
}

// useMemo と useCallback を手書き
export function useFilteredItems(items, query) {
  const [sel, setSel] = useState(null);
  const filtered = useMemo(() => items.filter(i => i.includes(query)), [items, query]);
  const onPick = useCallback((i) => setSel(i), []);
  return { filtered, onPick, sel };
}

// 変換結果: どちらも46行、import を除いた本体が一致

コンパイラはキャッシュ領域_c(9)を確保し、filteredの計算、onPickの関数参照、返り値のオブジェクト{ filtered, onPick, sel }をそれぞれメモ化します。この例では、前章で手書きした「返り値の固定」をコンパイラが肩代わりしています。ただしReact Compilerの紹介ページは、新しいコードではコンパイラに任せて精密な制御が要る箇所だけuseMemo・useCallbackを使い、既存のメモ化は「removing it can change compilation output」として残すか、十分にテストしてから外すよう勧めています。Effectの依存値になる値を確実に固定したい場合も、手書きのメモ化を残す理由になります。導入手順はReact Compilerとは?Vite・Next.js・Expoでの導入とRust版・ESLint・useMemoの扱い【2026年10月】で解説しています。

コンパイラでも直らない3つの書き方

コンパイラは万能ではありません。今回の実測で、変換後も問題が残ったのは次の3つです。

書き方 Compiler適用後
フックを呼ばないuse関数 変換対象外(runtimeのimportも無し)
引数の既定値 options = {} Effect 6回のまま
フック内のコンポーネント定義 開閉のたびに子が再マウント

1つ目は、既定のcompilationMode: 'infer'では、コンパイラが名前と中身からコンポーネントやフックかどうかを推定するためです(今回の設定は既定のまま)。フックを呼ばないuseFilteredItemsは入力とほぼ同じ形で出力され、react/compiler-runtimeのimportすら入りませんでした。命名は最適化が効くかどうかに直結します。コンパイラを導入していないプロジェクトでは、返り値が依存配列やmemo化した子のpropsに渡る用途に限って、前章のように手で固定します。

外部ストアを購読するカスタムフックとuseSyncExternalStore

購読と解除の最小実装

ブラウザのAPIや自前のストアなど、Reactの外にある値を購読するカスタムフックは、useEffectとuseStateの組み合わせではなくuseSyncExternalStoreで書きます。React 19.3.1で購読と解除まで確認したコードです。

const store = {
  online: true,
  listeners: new Set(),
  subscribe(cb) { store.listeners.add(cb); return () => store.listeners.delete(cb); },
  set(v) { store.online = v; store.listeners.forEach(l => l()); },
};

function useOnlineStatus() {
  return useSyncExternalStore(store.subscribe, () => store.online, () => true);
}

// 初期値 = true、listeners = 1
// store.set(false) 後 = false
// unmount 後 listeners = 0

無限ループとサーバーレンダリングでつまずく箇所

第2引数のgetSnapshotが毎回新しいオブジェクトを返すと無限ループになります。useSyncExternalStoreのリファレンスは「if you always return a different value, you will enter an infinite loop」と明記しています。ストアの値をそのまま返すか、変化したときだけ新しい参照を返してください。

第3引数のgetServerSnapshotを省くと、サーバーでの描画時にエラーが投げられます。上の例では固定値trueを返しています。第1引数のsubscribeは参照が変わるたびに再購読されるので、コンポーネントの外で定義します。なお<Activity>で非表示にしている間のストア変更を取りこぼす不具合は、React 19.3.0で修正されました(#36947)。

カスタムフックのテスト:renderHookの最小構成

カスタムフックはコンポーネントから切り離してテストできます。Testing LibraryのrenderHookを使えば、テスト用のダミーコンポーネントを書かずに済みます。@testing-library/react 16.3.3(peer依存はreact・react-domの^18.0.0 || ^19.0.0と@testing-library/dom ^10.0.0)とReact 19.3.1を、Node.js標準のテストランナーとjsdom 30.1.3で動かした全文です。

import { JSDOM } from 'jsdom';
import test from 'node:test';
import assert from 'node:assert/strict';

const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/' });
global.window = dom.window;
global.document = dom.window.document;

// jsdom の global を用意した後に読み込むため動的 import にする
const { useState, useCallback } = await import('react');
const { renderHook, act, cleanup } = await import('@testing-library/react');

// node:test にはグローバルの afterEach が無いので自動 cleanup を自分で登録する
test.afterEach(cleanup);

function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = useCallback(() => setCount(c => c + 1), []);
  return { count, increment };
}

test('初期値を返す', () => {
  const { result } = renderHook(() => useCounter(5));
  assert.equal(result.current.count, 5);
});

test('incrementで1増える', () => {
  const { result } = renderHook(() => useCounter());
  act(() => result.current.increment());
  assert.equal(result.current.count, 1);
});

test('初期値の引数を変えてもcountは変わらない', () => {
  const { result, rerender } = renderHook(({ n }) => useCounter(n), { initialProps: { n: 1 } });
  rerender({ n: 9 });
  assert.equal(result.current.count, 1);
});

// node --test final.test.mjs
// tests 3、pass 3、fail 0

状態を変える操作はactで囲みます。3つ目のテストは、useStateの初期値が最初のレンダーでしか使われないことを確かめています。引数の変化に追従させたいフックなら、期待値を9に変えたテストを先に書き、それが通るように実装を直します。Testing Libraryの自動cleanupはテストランナーのグローバルなafterEachに登録されるため、それが無いnode:testでは働きません。test.afterEach(cleanup)を省くと、3件の実行後もDOMコンテナが3個残りました。await importにしているのは、ESMの静的importが巻き上げられ、jsdomのglobal設定より先にReactが読み込まれるのを避けるためです。

よくある質問

カスタムフックとは何ですか?

useStateやuseEffectなどの標準フックを内部で呼び出す、use接頭辞の付いた自作関数です。コンポーネント間でロジックを再利用するための仕組みで、状態そのものは共有されません。同じカスタムフックを2つのコンポーネントで呼ぶと、状態はそれぞれ独立します。

Reactのフックとカスタムフックはどう違いますか?

useStateやuseEffectなどReact本体が提供するものが標準フックで、それらを組み合わせてアプリケーション側で定義するのがカスタムフックです。トップレベルでのみ呼ぶ、条件分岐やループの中で呼ばない、という呼び出しのルールは両者とも同じで、eslint-plugin-react-hooksのrules-of-hooksが検査します。

カスタムフックの命名規則は?

useに続けて大文字で始めます(例:useOnlineStatus)。内部でフックを1つも呼ばない関数にはuseを付けず、getSortedのような通常の関数名にします。useを付けないままフックを呼ぶとESLintがerrorにしますが、フックを呼ばないのにuseを付けた場合は警告が出ません。

カスタムフックの中で別のカスタムフックを呼んでもよいですか?

呼べます。react.devも「Only Hooks and components can call other Hooks!」と書いており、フックの中から別のカスタムフックを呼ぶのは想定された使い方です。ルールは標準フックと同じで、トップレベルで毎回同じ順序で呼ぶ必要があります。引数は呼ばれるたびに最新の値が渡るので、Effectで使う引数は依存配列に入れます。

ライブラリが提供するフックにも同じルールが適用されますか?

適用されます。React Hook Formとは?使い方とv7の書き方・検証タイミング【7.91対応】のuseFormのように外部ライブラリが提供するフックも、トップレベルでのみ呼ぶ制約は同じです。包む側の自作フックが新しいオブジェクトを組み立てて返す場合、その参照が毎回変わるかどうかは包む側の実装で決まるので、依存配列に入る用途かを見て固定するか判断します。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

RELATED POSTS 関連記事

目次