🪝

【React】「とりあえず切り出す」をやめる - カスタムフック設計で大事なこと

に公開

こんにちは!
株式会社Sally エンジニアの haruten です♪

私たち株式会社Sallyでは、マーダーミステリーをスマホやPCで遊べるアプリ「ウズ」や、マーダーミステリーを制作してウズ上で公開・プレイできるエディターツール「ウズスタジオ」などを開発・運営しています。
https://sally-inc.jp/

今回はReactのカスタムフックの設計について学び直す機会があったので、その備忘録です!

はじめに

カスタムフックは、Reactにおけるロジックの再利用を可能にする強力な機能です。

しかし最近、とりあえずスパゲッティコードを切り出して分割するだけに利用していることに気づき、改めてカスタムフックの最適な設計を意識しました。

この記事では、3つの重要な設計原則を軸に、実務で使えるカスタムフックのパターンを紹介します。良い例・悪い例を比較しながら、「なぜそうすべきか」を説明しています。

この記事で学べる3つの原則

  1. 単一責任 - 1つのフックは1つのことだけをする
  2. 直感的なAPI - 使いやすいインターフェース設計
  3. パフォーマンス - 適切な最適化の判断基準

そもそもカスタムフックとは?

まず、3つの原則に入る前に、カスタムフックの基本を確認しておきましょう。

なぜ存在するのか?

Reactコンポーネントを書いていると、同じようなロジックを複数のコンポーネントで使い回したくなることがあります(フォーム管理、データ取得、イベント監視など)。

React Hooks 以前は、HOC(Higher Order Component)や Render Props でロジックを共有していましたが、コンポーネントの階層が深くなる「ラッパー地獄」が問題でした。

カスタムフックは、コンポーネントの階層を増やさずに、ロジックだけを共有するための仕組みです。

カスタムフックを作るべきタイミング

  1. 同じロジックを複数箇所で使っている時 - 重複を避けて再利用する
  2. コンポーネントのロジックが複雑になってきた時 - ロジックとUIを分離してシンプルに保つ
  3. 副作用を適切に管理したい時 - 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の標準フック(useStateuseReducerなど)の形式に合わせることで、使い方が予測しやすくなります。

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アプリケーションを構築できます。

カスタムフックは強力なツールですが、「本当にカスタムフックにすべきか?」を常に問いかけることも大切です。

シンプルなコンポーネント内のロジックまで無理にフック化する必要はありません。適材適所で活用しましょう。

この記事が皆さんのお役に立つと嬉しいです!
最後まで読んでいただきありがとうございました!

UZU テックブログ

Discussion