Reactカスタムフックのベストプラクティス|命名・返り値・メモ化の判断基準
カスタムフックは「useから始まる関数に切り出す」と説明されがちですが、実務で判断に迷うのはその先です。返り値は配列とオブジェクトのどちらにすべきか、useCallbackで包む必要はあるのか、自作したEffectラッパーの依存配列は誰が検査してくれるのか。この記事では、React 19.2.8・eslint-plugin-react-hooks 7.1.1・React Compiler 1.0を実際に動かして得た結果をもとに、カスタムフック設計の判断基準を整理します。掲載しているコードと数値は、すべてmacOS上のNode.js 26.5.0で実行した結果です。
まとめ
カスタムフックのベストプラクティスは、次の6点に集約できます。
| 論点 | 結論 | 根拠 |
|---|---|---|
| 命名 | フックを呼ぶ関数だけuse接頭辞 | lintが素通しする違反あり |
| 返り値(配列) | as constを付ける | 無いとTS2349 |
| 返り値(オブジェクト) | useMemoで固定 | Effect実行が6回対1回 |
| Effectラッパー | additionalEffectHooksを設定 | 既定は依存配列が未検査 |
| 手書きメモ化 | Compiler導入済みなら不要 | 変換後の出力が一致 |
| 切り出し単位 | 用途単位 | ライフサイクル模倣は公式が非推奨 |
とくに判断が割れるのが5番目のメモ化です。React Compilerを導入した環境では手書きのuseMemoとuseCallbackが出力コードに一切影響しないことを実測で確認しました。以下、各項目の根拠を順に見ていきます。
カスタムフックの定義と、切り出しを判断する基準
カスタムフックと標準フックの違い、共有される範囲
カスタムフックは、useStateやuseEffectといった標準フックを内部で呼び出す、use接頭辞の付いた自作関数です。標準フックがReact本体から提供されるのに対し、カスタムフックはアプリケーション側で定義します。標準フック18種それぞれの役割はReactフック(Hooks)とは?全18種の一覧とReact Compiler時代の使い分け【2026年8月版】で整理しています。
設計上もっとも重要なのは、共有されるのがロジックであって状態そのものではないという点です。react.devは「Custom Hooks let you share stateful logic but not state itself.」と明記しています。同じカスタムフックを2つのコンポーネントで呼んでも、状態は独立します。React 19.2.8で実際に確認した結果が次のとおりです。
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><span>A=1</span><span>B=100</span></div>
// useCounterValue 呼び出し回数 = 2
AとBは同じフックを使っていますが値は混ざりません。複数のコンポーネントで状態そのものを共有したい場合は、カスタムフックではなく状態のリフトアップかContextを使います。Contextの実装はReact Contextの使い方|createContext・useContextの実装とReact 19の新記法で扱っています。
切り出す単位と、切り出さない方がよい単位
カスタムフックは用途単位で切り出します。react.devが挙げる良い例はuseData(url)やuseChatRoom(options)のように、何をするフックかが名前から分かるものです。
一方、react.devが明確に避けるべきとしているのが、useEffectの薄いラッパーとして作るライフサイクル模倣フックです。useMount(fn)、useEffectOnce(fn)、useUpdateEffect(fn)の3つが名指しで挙げられており、公式ドキュメントは「Avoid creating and using custom “lifecycle” Hooks that act as alternatives and convenience wrappers for the useEffect API itself」と述べています。クラスコンポーネント時代のcomponentDidMountを再現したくなる場面ほど、この落とし穴にはまります。
フックを1つも呼ばない処理も切り出し対象外です。配列をソートするだけの関数にuseを付けると、条件分岐の中で呼べなくなるという実害だけが残ります。後述するとおり、React Compilerの最適化対象からも外れます。
命名規則:use接頭辞の条件とESLintが見ていない範囲
use接頭辞が必要になる条件
フック名はuseに続けて大文字で始めるのがReactの規約です。この命名はスタイルの問題ではなく、Reactとリンタが「この関数はフックである」と判別する唯一の手掛かりになっています。逆に、内部でフックを一切呼ばない関数にuseを付けてはいけません。react.devはソート関数の例でuseSortedではなくgetSortedと名付けるよう示しています。
ESLint 7系が検出する違反と、素通しする違反
ここで実務上の注意点があります。命名規則の違反がすべてリンタで検出されるわけではありません。eslint-plugin-react-hooks 7.1.1をESLint 10.8.0で実行し、4種類の違反を含むファイルを検査しました。以下は命名に関わる3件の抜粋です(4件目のEffectラッパーは後の章で扱います)。
// (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)は無言で通ります。公式ドキュメントが避けるよう求めている命名でも、リンタは教えてくれないということです。この種の規約違反はレビューか、React Doctorとは?npxで使うReactコード診断ツールの使い方・スコア・ESLintとの違いで扱っているような静的診断ツールで拾う必要があります。
なお、ESLint 10のフラット設定で7系プラグインを読み込む際は、設定オブジェクトの取り出し方に注意してください。reactHooks.configs['recommended-latest']はpluginsが文字列配列のためフラット設定では読み込めず、reactHooks.configs.flat['recommended-latest']を使います。
返り値の設計:配列のas constとオブジェクトの参照安定性
配列で返す場合のas const
useStateのように[値, 更新関数]の形で返したい場合、TypeScriptではas constが必須です。付けないと配列がユニオン型の配列として推論され、呼び出し側で壊れます。TypeScript 7.0.2と@types/react 19.2.18で検証した結果は次のとおりです。
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]
}
export function P() { const [a, toggleA] = useToggleA(false); toggleA(); return a; }
// 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); toggleB(); return b; }
// エラーなし
返す要素が3つ以上に増えるとタプルの位置を覚えるコストが上がるため、その段階でオブジェクト返却へ切り替える方が読み手に優しくなります。判断の目安は、返り値が2つまでなら配列、3つ以上ならオブジェクトです。
オブジェクトで返す場合の再実行コスト
オブジェクトを返す場合は参照の安定性が問題になります。毎レンダーで新しいオブジェクトを作ると、呼び出し側がその値を依存配列に入れた瞬間にEffectが毎回再実行されます。React 19.2.8をjsdom上で描画し、親コンポーネントを5回再レンダーしたときのEffect実行回数を計測しました。
// 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]);
}
// 同じコンポーネントを A と B それぞれで組み立てて計測する
function make(useApi) {
return function Screen({ tick }) {
const api = useApi('/items');
useEffect(() => { effectRuns++; }, [api]);
return <span>{tick}</span>;
};
}
run(make(useApiA)); // useEffect 実行回数 = 6
run(make(useApiB)); // useEffect 実行回数 = 1
Aの6回は初回1回と再レンダー5回の合計です。呼び出し側からは制御できないため、カスタムフックを提供する側が固定する責任を負います。ただし次章で述べるとおり、React Compilerを導入している場合はこの手当てが不要になります。
Effectを内包するカスタムフックの依存配列
自作Effectフックの依存配列が既定で検査されない問題
useEffectをラップしたカスタムフックを作り、依存配列を引数で受け取って素通しする設計は珍しくありません。この場合、既定の設定ではexhaustive-depsが依存配列を検査しません。同じファイルに対し、設定の有無だけを変えて検出件数を比較しました。
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-latest'],
languageOptions: {
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
},
settings: {
'react-hooks': { additionalEffectHooks: '^useIsomorphicLayoutEffect$' },
},
},
];
// settings あり : 4 problems (2 errors, 2 warnings)
// warning React Hook useIsomorphicLayoutEffect has a missing dependency: 'text'
// settings なし : 3 problems (2 errors, 1 warning) ← 上記の警告が出ない
7系ではこのadditionalEffectHooksがsettings経由の共有設定になっており、exhaustive-depsとrules-of-hooksの両方が同じ値を参照します。ルール個別のoptionsとしてadditionalHooksを渡す旧来の書き方も残っていますが、settings側に寄せる方が2つのルールで設定が重複せず管理しやすくなります。自作のEffectラッパーを1つでも持っているなら、この設定を入れない限り依存配列の検査は丸ごと抜け落ちたままです。
なおlanguageOptionsの指定を省くとJSXがパースできず、上の比較そのものが走りません。プラグインのプリセットはparser設定を含まないため、フラット設定側で明示する必要があります。
useEffectEventによるコールバックの切り離し
カスタムフックがコールバックを引数で受け取り、それをEffectの依存配列に入れると、呼び出し側が毎回新しい関数を渡すたびに接続や購読がやり直されます。React 19.2でリリースされたuseEffectEventは、この問題を依存配列から外すことで解決します。[email protected]のビルド出力を確認したところ、exports.useEffectEventにexperimental_やunstable_の接頭辞は付いておらず、実行時も通常の関数として取得できました。
// 素直に依存配列へ入れた場合
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]); // onReceive を依存配列に含めない
}
// 親を5回再レンダーしたときの接続回数
// useEffectEvent なし : 6
// useEffectEvent あり : 1
使用条件は厳格です。Effect EventはEffectの内部か他のEffect Eventからのみ呼び出せ、レンダー中に呼んだり他のコンポーネントやフックへ渡したりはできません。またEffect Eventの識別子は毎レンダー意図的に変わるため、依存配列に入れてはいけません。
React Compiler 1.0導入下で手書きメモ化を残すかの判断
手書きuseMemoの有無で変換結果が変わらない実測
カスタムフックの解説記事の多くは「返す関数は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行、後者 46行、本体が同一か: true
importを除いた変換後の本体が完全に一致しました。コンパイラはキャッシュスロット_c(9)を確保し、filteredの計算だけでなくonPickの関数参照、さらに返り値のオブジェクト{ filtered, onPick, sel }まで個別にメモ化します。前章で扱った「返り値のオブジェクトを固定する」手当ても、コンパイラが自動で肩代わりするということです。
したがって、React Compilerを導入済みのプロジェクトでカスタムフックに手書きのuseMemoやuseCallbackを足すのは、可読性を落とすだけで得るものがありません。新規に書くコードでは省くべきです。既存の手書きメモ化は出力に影響しないので急いで剥がす必要はなく、触るファイルから順に削るので十分です。コンパイラ自体の導入手順はReact Compilerとは?自動メモ化の仕組みと導入方法・useMemoとの違いを解説で解説しています。
コンパイラが触らないカスタムフック
ただし例外があります。フックを1つも呼ばないuse接頭辞の関数は、コンパイラの変換対象になりません。
export function useFilteredItems(items, query) {
const filtered = items.filter(i => i.includes(query));
return { filtered };
}
// 変換後: 入力とほぼ同一。react/compiler-runtime の import も追加されない
コンパイラは対象をコンポーネントとフックに限定して判別しており、内部でフックを呼ばない関数はその判定から外れます。命名規則の話は見た目の統一だけの問題ではなく、最適化が効くかどうかに直結しているわけです。
React Compilerを導入していないプロジェクトでは、前章の実測どおり手書きのメモ化が引き続き必要です。判断は単純で、コンパイラを入れているなら書かない、入れていないなら返り値のオブジェクトと関数だけは固定する、となります。
外部ストアを購読するカスタムフックとuseSyncExternalStore
購読と解除の最小実装
Reactの外側で管理されている値、たとえばブラウザのAPIや自前のストアを購読する場合は、useEffectとuseStateを組み合わせるよりuseSyncExternalStoreを使います。React 19.2.8で購読と解除まで動作を確認したのが次のコードです。
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
無限ループとサーバーレンダリングでつまずく箇所
注意点は3つあります。1つ目は、第2引数のgetSnapshotが毎回新しいオブジェクトを返すと無限ループになることです。react.devは「if you always return a different value, you will enter an infinite loop」と明記しています。ストアの値をそのまま返すか、変化があったときだけ新しい参照を返す実装にしてください。
2つ目は、第3引数のgetServerSnapshotを省略するとサーバーレンダリング時にエラーになる点です。上の例では固定値trueを返しています。3つ目は第1引数のsubscribeで、参照が変わると再購読が走ります。上の例のようにコンポーネントの外に定義するか、useCallbackで包んでください。
カスタムフックのテスト構成
renderHookで動かす最小構成
カスタムフックはコンポーネントから独立してテストできます。Testing LibraryのrenderHookを使えば、ダミーコンポーネントを自作せずに済みます。@testing-library/react 16.3.2とReact 19.2.8を、Node.js 26.5.0標準のテストランナーとjsdomの組み合わせで動かした全文が次のとおりです。
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 } = await import('@testing-library/react');
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('呼び出しごとにstateは独立する', () => {
const a = renderHook(() => useCounter(1));
const b = renderHook(() => useCounter(100));
act(() => a.result.current.increment());
assert.equal(a.result.current.count, 2);
assert.equal(b.result.current.count, 100);
});
// node --test final.test.mjs
// tests 3、pass 3、fail 0
状態を更新する操作は必ずactで囲みます。囲まないと更新が反映される前にアサーションが走ります。ReactとTesting Libraryをstatic importでなくawait importで読み込んでいるのは、ESMのimportが巻き上げられてjsdomのglobal設定より先に走るのを避けるためです。
なおglobal.IS_REACT_ACT_ENVIRONMENT = trueを明示する必要はありません。@testing-library/react 16.3.2はactの実行時に自分でこのフラグを立てます。Testing Libraryを経由せずReact本体のactを直接呼ぶ場合だけ、自分で設定してください。
よくある質問
カスタムフックとは何ですか?
useStateやuseEffectといった標準フックを内部で呼び出す、use接頭辞の付いた自作関数です。コンポーネント間でロジックを再利用するための仕組みで、共有されるのはロジックであって状態そのものではありません。同じカスタムフックを2つのコンポーネントで呼ぶと、状態はそれぞれ独立します。
Reactのフックとカスタムフックはどう違いますか?
useStateやuseEffectなどReact本体が提供する18種類が標準フックで、それらを組み合わせてアプリケーション側で定義するのがカスタムフックです。呼び出しのルール(トップレベルでのみ呼ぶ、条件分岐やループの中で呼ばない)は両者とも同じで、eslint-plugin-react-hooksのrules-of-hooksが検査します。標準フック個々の使い分けはReactフック(Hooks)とは?全18種の一覧とReact Compiler時代の使い分け【2026年8月版】で扱っています。
useStateとuseEffectの違いは何ですか?
useStateは再レンダーをまたいで値を保持し、更新時に再レンダーを起こすフックです。useEffectはレンダー結果が画面へ反映された後に、外部システムとの同期処理を実行するフックです。カスタムフックの中ではこの2つを組み合わせることが多く、useEffectを使うときは戻り値でクリーンアップ関数を返し、購読やタイマーを解除してください。
カスタムフックの中でデータ取得をしてもよいですか?
可能ですが、useEffect内でfetchを直接呼ぶ実装はreact.devが推奨していません。フレームワークのデータ取得機構か、専用のライブラリを使う方が競合状態やキャッシュの問題を避けられます。自前で書く場合は、AbortControllerでクリーンアップ時にリクエストを中断する処理を必ず入れてください。
ライブラリが提供するフックにも同じルールが適用されますか?
適用されます。React Hook Formとは?使い方・バリデーション・v7の書き方を実例で解説のuseFormのように外部ライブラリが提供するフックも、トップレベルでのみ呼ぶという制約は同じです。ただしライブラリ側のフックをさらに自作フックでラップする場合、返り値の参照安定性は自分で担保する必要があります。