【React】「とりあえず切り出す」をやめる - カスタムフック設計で大事なこと
こんにちは!
株式会社Sally エンジニアの haruten です♪
私たち株式会社Sallyでは、マーダーミステリーをスマホやPCで遊べるアプリ「ウズ」や、マーダーミステリーを制作してウズ上で公開・プレイできるエディターツール「ウズスタジオ」などを開発・運営しています。
今回はReactのカスタムフックの設計について学び直す機会があったので、その備忘録です!
はじめに
カスタムフックは、Reactにおけるロジックの再利用を可能にする強力な機能です。
しかし最近、とりあえずスパゲッティコードを切り出して分割するだけに利用していることに気づき、改めてカスタムフックの最適な設計を意識しました。
この記事では、3つの重要な設計原則を軸に、実務で使えるカスタムフックのパターンを紹介します。良い例・悪い例を比較しながら、「なぜそうすべきか」を説明しています。
この記事で学べる3つの原則
- 単一責任 - 1つのフックは1つのことだけをする
- 直感的なAPI - 使いやすいインターフェース設計
- パフォーマンス - 適切な最適化の判断基準
そもそもカスタムフックとは?
まず、3つの原則に入る前に、カスタムフックの基本を確認しておきましょう。
なぜ存在するのか?
Reactコンポーネントを書いていると、同じようなロジックを複数のコンポーネントで使い回したくなることがあります(フォーム管理、データ取得、イベント監視など)。
React Hooks 以前は、HOC(Higher Order Component)や Render Props でロジックを共有していましたが、コンポーネントの階層が深くなる「ラッパー地獄」が問題でした。
カスタムフックは、コンポーネントの階層を増やさずに、ロジックだけを共有するための仕組みです。
カスタムフックを作るべきタイミング
- 同じロジックを複数箇所で使っている時 - 重複を避けて再利用する
- コンポーネントのロジックが複雑になってきた時 - ロジックとUIを分離してシンプルに保つ
- 副作用を適切に管理したい時 - API呼び出しやイベントリスナーなどをカプセル化
アンチパターン
- 1箇所でしか使わないロジック - まずはコンポーネント内に書く。必要になってから切り出す
- 単純な計算や変換 - 普通の関数で十分
カスタムフックは「後から切り出す」こともできます。最初から完璧を目指さず、重複が出てきたタイミングで抽出するのも良いアプローチです。
冒頭で述べたような「コードが長くなったから、とりあえずカスタムフックに切り出す」というアプローチは、本来の目的からズレています。大切なのはロジックの再利用性や責務の分離であり、単なるコード分割ではありません。
1. 単一責任:1つのフックは1つのことだけをする
カスタムフックを設計する上で最も重要な原則です。1つのフックが複数の無関係な機能を持つと、再利用性が著しく低下します。
❌ 悪い例:責務が多すぎる
// 複数の責務を持ちすぎ
function useEverything() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [theme, setTheme] = useState('light');
const [isOpen, setIsOpen] = useState(false);
// 関係ない機能が混在
useEffect(() => {
fetchUser();
fetchPosts();
}, []);
return { user, posts, theme, isOpen, setTheme, setIsOpen };
}
何が問題か?
このフックを使いたいコンポーネントは、必要のない状態(例:ユーザー情報だけ欲しいのにテーマやモーダルの状態も一緒についてくる)も抱え込むことになります。さらに、テストも困難です。
✅ 良い例:単一の責務に集中
// トグル状態の管理だけに集中
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
なぜ良いのか?
- トグル状態の管理という1つの明確な責務だけを持つ
- どこでも再利用できる(モーダル、アコーディオン、テーマ切り替えなど)
- テストが容易
判断基準:
「このフックは〇〇をする」と一言で説明できるか?
説明に「と」「や」が入るなら、分割を検討した方がいいでしょう。
2. 直感的なAPI
カスタムフックのAPIは、一貫性があり、予測可能であるべきです。
Reactの標準フック(useState
、useReducer
など)の形式に合わせることで、使い方が予測しやすくなります。
APIの設計パターン
パターン1: 配列形式 - useState
スタイル
const [value, setValue] = useLocalStorage('key', initialValue);
シンプルで、1つの値とその更新関数を返す場合に最適。
パターン2: オブジェクト形式 - 複数の値を返す場合
const { data, error, isLoading, refetch } = useQuery(url);
返り値が多い場合、名前で識別できるので分かりやすい。
パターン3: ハンドラを分離 - 状態と操作を明確に区別
const [items, { add, remove, clear }] = useArray([]);
値は直接参照、操作はオブジェクトにまとめることで役割が明確に。
重要なのは、プロジェクト内で一貫したパターンを使うことです。
実践例1: useBoolean
モーダルの開閉やトグルボタンなど、ON/OFFの状態を扱う場面で便利なフックです。
function useBoolean(initialValue = false) {
const [value, setValue] = useState(initialValue);
const handlers = useMemo(
() => ({
setTrue: () => setValue(true),
setFalse: () => setValue(false),
toggle: () => setValue(v => !v),
}),
[]
);
return [value, handlers];
}
// 使用例
function Modal() {
const [isOpen, { setTrue: open, setFalse: close }] = useBoolean();
return (
<>
<button onClick={open}>開く</button>
{isOpen && <ModalContent onClose={close} />}
</>
);
}
ポイント:
- 配列 + オブジェクトの組み合わせで、値とハンドラを明確に分離
- 分割代入でハンドラ名を変更できる柔軟性(
setTrue: open
)
実践例2: useLocalStorage
ユーザー設定(テーマ、言語など)をブラウザに保存したい場合に使えるフックです。useState
と同じ使い心地で、LocalStorageへの保存も自動で行います。
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, Dispatch<SetStateAction<T>>] {
// 初期値の読み込み
const [storedValue, setStoredValue] = useState<T>(() => {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
});
// 状態が変更されたら LocalStorage に保存
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.error(`Error saving localStorage key "${key}":`, error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
// 使用例
function ThemeSelector() {
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Current: {theme}
</button>
);
}
ポイント:
-
useState
と完全に同じAPIで、LocalStorageへの追加も可能 - APIがシンプルに保たれている
3. パフォーマンス:カスタムフック内での最適化
カスタムフックを設計する際、パフォーマンスを意識しすぎて複雑になってしまうことがあります。シンプルさを保ちつつ、必要な箇所だけ最適化するのが重要です。
原則1: フックが返す関数は必ずメモ化する
カスタムフックが返す関数は、コンポーネントの再レンダリングのたびに新しい関数が生成されないよう、useCallback
でメモ化すべきです。
❌ 悪い例:関数をメモ化していない
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 };
}
// このフックを使うコンポーネントでは、
// increment などが毎回変わるため、useEffect の依存配列に入れると毎回実行される
✅ 良い例:関数をメモ化している
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset };
}
ポイント:
- フックが返す関数は、使う側のコンポーネントで
useEffect
の依存配列に入る可能性がある - メモ化しないと、意図しない再実行を引き起こす
原則2: 重い計算が必要なら、カスタムフックに切り出す
コンポーネント内で複雑な計算やフィルタリングを行っている場合、それをカスタムフックに切り出すことで、再利用性とテスタビリティが向上します。
// カスタムフックに切り出す
function useFilteredItems<T>(items: T[], filterFn: (item: T) => boolean) {
return useMemo(() => items.filter(filterFn), [items, filterFn]);
}
// 使用例
function ProductList({ products }: { products: Product[] }) {
const [searchTerm, setSearchTerm] = useState('');
// フィルタリングロジックをフックに委譲
const filteredProducts = useFilteredItems(
products,
(product) => product.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{filteredProducts.map(product => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
}
ポイント:
- 重い計算をフックに隠蔽することで、コンポーネントがシンプルになる
-
useMemo
による最適化もフック内部で完結 - 同じフィルタリングロジックを他のコンポーネントでも再利用可能
原則3: 過度な最適化は避ける
カスタムフックを作る時、「将来使うかもしれない」機能を先回りして実装したり、不要な最適化をしてしまうことがあります。
❌ 悪い例:やりすぎ
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
// 全部メモ化する必要はない(countは単なる数値)
const doubledCount = useMemo(() => count * 2, [count]);
const isEven = useMemo(() => count % 2 === 0, [count]);
const isPositive = useMemo(() => count > 0, [count]);
// 使われないかもしれない機能まで実装
const incrementBy = useCallback((n: number) => setCount(c => c + n), []);
const decrementBy = useCallback((n: number) => setCount(c => c - n), []);
const multiplyBy = useCallback((n: number) => setCount(c => c * n), []);
return {
count,
doubledCount,
isEven,
isPositive,
incrementBy,
decrementBy,
multiplyBy,
};
}
✅ 良い例:シンプルに保つ
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
// 必要最小限の機能だけ提供
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset };
}
判断基準:
- YAGNI原則(You Aren't Gonna Need It): 今必要ないものは作らない
- 簡単な計算(count * 2 など)はメモ化不要
- 実際に使われる機能だけを実装する
まとめ
カスタムフックを設計する際の3つの重要な原則を振り返ります。
1. 単一責任の原則
- 1つのフックは1つのことだけをする
- 「このフックは〇〇をする」と一言で説明できるか?
- 説明に「と」「や」が入るなら分割を検討する
2. 直感的なAPI
- 配列形式
[value, setValue]
、オブジェクト形式{ data, error }
、ハンドラ分離[value, { add, remove }]
を使い分ける - Reactの標準フック(
useState
など)と同じ形式にすると使い方が予測しやすい - プロジェクト内で一貫したパターンを使う
3. パフォーマンス
- フックが返す関数は必ず
useCallback
でメモ化する(useEffect
の依存配列問題を防ぐ) - 重い計算はカスタムフックに切り出して
useMemo
で最適化 - YAGNI原則: 今必要ない機能は実装しない、過度な最適化は避ける
これらの原則を理解しておけば、再利用性が高く、保守しやすいReactアプリケーションを構築できます。
カスタムフックは強力なツールですが、「本当にカスタムフックにすべきか?」を常に問いかけることも大切です。
シンプルなコンポーネント内のロジックまで無理にフック化する必要はありません。適材適所で活用しましょう。
この記事が皆さんのお役に立つと嬉しいです!
最後まで読んでいただきありがとうございました!
Discussion