🛠️

カスタムフックへの分離を考える時の思考プロセス(初学者向け)

2024/12/26に公開

カスタムフックへの分離を考える時の思考プロセス

はじめに

フロントエンドエンジニア2年目として、より良いReactの設計パターンを学び直す中で、カスタムフックの本質的な価値について考え直す機会がありました。
「カスタムフックでどこまでロジックを分離すべきか」「どのような粒度でフックを作成するのが効果的か」といった実装の詳細な部分で、改めて深く考える必要性を感じました。特に、コードの再利用性とテスタビリティを高めるための、カスタムフックの設計思想について、体系的に整理したいと考えました。
今回は、シンプルなTodoリストを例に、カスタムフックによるロジックの分離とその効果的な実装方法について、自分への学び直しも込めて解説していきます。

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

カスタムフックは、コンポーネントからロジック(処理の部分)を分離して再利用可能にする機能です。

例えば、以下のようなtodolistのコンポーネントがあるとします。

import { useEffect, useState } from "react";
import { Todo } from "./types/Todo";
import TodoList from "./components/TodoList";
import "./style/App.css";

const App = () => {
  const [todos, setTodos] = useState<Todo[]>([
    { id: 1, title: "Learn React", completed: false },
    { id: 2, title: "TypeScript", completed: false },
  ]);
  const [searchKeyword, setSearchKeyword] = useState<string>("");
  const [filteredTodos, setFilteredTodos] = useState<Todo[]>([]);
  const [newTodo, setNewTodo] = useState<string>("");

  useEffect(() => {
    const filtered = todos.filter((todo) =>
      todo.title.toLowerCase().startsWith(searchKeyword.toLowerCase())
    );
    setFilteredTodos(filtered);
  }, [searchKeyword, todos]);

  const addTodo = () => {
    if (!newTodo.trim()) return;
    const newTodoItem: Todo = {
      id: todos.length + 1,
      title: newTodo,
      completed: false,
    };
    setTodos([...todos, newTodoItem]);
    setNewTodo("");
  };

  const deleteTodo = (id: number) => {
    setTodos(todos.filter((todo) => todo.id !== id));
  };

  return (
    <div className="container">
      <h2 className="title">タスクを調べる</h2>

      <div className="form-group">
        <p>検索</p>
        <input
          type="text"
          placeholder="Search Todos..."
          value={searchKeyword}
          onChange={(e) => setSearchKeyword(e.target.value)}
          className="input-field"
        />
      </div>

      <div className="form-group">
        <p>追加</p>
        <input
          type="text"
          value={newTodo}
          onChange={(e) => setNewTodo(e.target.value)}
          className="input-field"
        />
        <button onClick={addTodo} className="button">
          Add
        </button>
      </div>

      <div className="form-group">
        <p>検索結果</p>
        <TodoList todos={filteredTodos} onDelete={deleteTodo} />
      </div>

      <p>TodoList</p>
    </div>
  );
};

export default App;

このコードには2つの問題があります。

  1. ロジックとUIが混ざっていて読みにくい
  2. 他のコンポーネントで同じロジックを使いたい場合に、コードを複製する必要がある

なぜロジックを分離するのか?

1. コンポーネントの役割を明確にする

Reactのコンポーネントには主に2つの役割があります。

  • データの管理と処理(ロジック)
  • 画面の表示(UI)

これらが1つのファイルに混在していると

  • コードが読みにくくなる
  • バグが発生しやすくなる
  • 修正が難しくなる

2. 再利用性を高める

同じロジック(例:Todoの追加・削除機能)を複数のページで使いたい場合、カスタムフック化していないと同じコードをコピー&ペーストする必要があります。カスタムフック化することで、ロジックを一箇所で管理し、複数のコンポーネントで簡単に再利用できるようになります。

カスタムフック化の思考プロセス

実際のTodoリストを例に、カスタムフック化の思考プロセスを説明していきます。

以下のように考えていきます。

  1. コンポーネントの責務を分析
  2. 状態の関連性を分析
  3. ロジックのグループ化を考える
  4. カスタムフックの設計原則を検討
  5. 具体的な分離方針を決める

1. コンポーネントの責務を分析

現在のコンポーネントの責務を以下の観点で分析します。

考慮すること

  • どのようなデータ(状態)を管理しているか?
  • どのようなロジックを持っているか?
  • UIとロジックの境界はどこにあるか?

今回のコードでは以下のように整理できます。

// データ管理
- todos: Todoリストの状態
- searchKeyword: 検索キーワード
- filteredTodos: 検索結果
- newTodo: 新規Todo入力

// ロジック
- Todo追加処理
- Todo削除処理
- 検索処理

// UI
- 検索フォーム
- Todo追加フォーム
- Todoリスト表示

2. 状態の関連性を分析

考慮すること

  • どの状態が互いに密接に関連しているか?
  • どの状態がどのロジックで使用されているか?
  • 状態の依存関係はどうなっているか?
// 現在の状態
const [todos, setTodos] = useState<Todo[]>([...]);
const [searchKeyword, setSearchKeyword] = useState<string>("");
const [filteredTodos, setFilteredTodos] = useState<Todo[]>([]);
const [newTodo, setNewTodo] = useState<string>("");

わかったこと
以下のように「Todoリストの管理に関する状態」と「検索に関する状態」で分けることができる。

  1. Todoリストの管理に関する状態

    • todos: メインのTodoリスト
    • newTodo: 新規Todo入力用の状態
  2. 検索に関する状態

    • searchKeyword: 検索キーワード
    • filteredTodos: 検索結果のTodoリスト

3. ロジックのグループ化を考える

考慮すること

  • どのロジックが同じ目的で使用されているか?
  • どのロジックが同じ状態を操作しているか?
  • 再利用可能なロジックはどれか?

決定事項
Todoリストで使用するロジックと検索ロジックを分ける。

  1. Todoリストの操作ロジック
const addTodo = () => { ... };
const deleteTodo = (id: number) => { ... };
  1. 検索ロジック
useEffect(() => {
  const filtered = todos.filter((todo) =>
    todo.title.toLowerCase().startsWith(searchKeyword.toLowerCase())
  );
  setFilteredTodos(filtered);
}, [searchKeyword, todos]);

4. カスタムフックの設計原則を検討

考慮すること

  • 単一責任の原則は守れているか?
  • フックの入力と出力は明確か?
  • フックの命名は目的を適切に表現できているか?

設計

  1. useTodoList: Todoリストの基本的なCRUD操作を担当
  2. useTodoSearch: 検索機能に特化

5. 具体的な分離方針を決める

考慮すること

  • どの状態とロジックをどのフックに分離するか?
  • フック間の依存関係をどう設計するか?
  • コンポーネントとフックの間のインターフェースをどうするか?

useTodoListの設計

  1. 状態の管理

    • todos: メインのTodoリスト
    • newTodo: 新規Todo用の入力状態
  2. 操作関数の提供

    • addTodo: 新規Todo追加
    • deleteTodo: Todo削除
  3. 型定義

export type TodoList = {
  onDelete: (id: number) => void;
  todos: Todo[];
};

useTodoSearchの設計

  1. 依存関係の明確化

    • todos: 検索対象のデータ(useTodoListから提供)
    • deleteTodo: 削除機能(useTodoListから提供)
  2. 検索関連の状態管理

    • searchKeyword: 検索キーワード
    • filteredTodos: フィルタリング済みのTodoリスト
  3. 機能の統合

    • onDelete: deleteTodo関数のエイリアス

実践:Todoリストをカスタムフック化する

上記の設計をもとにカスタムフックを実装していく。

カスタムフックの実装

useTodoList.tsx

export const useTodoList = () => {
  const [todos, setTodos] = useState<Todo[]>([
    { id: 1, title: "Learn React", completed: false },
    { id: 2, title: "TypeScript", completed: false },
  ]);
  const [newTodo, setNewTodo] = useState("");

  const addTodo = () => {
    if (!newTodo.trim()) return;
    const newTodoItem: Todo = {
      id: todos.length + 1,
      title: newTodo,
      completed: false,
    };
    setTodos([...todos, newTodoItem]);
    setNewTodo("");
  };

  const deleteTodo = (id: number) => {
    setTodos(todos.filter((todo) => todo.id !== id));
  };

  return {
    todos,
    newTodo,
    setNewTodo,
    addTodo,
    deleteTodo,
  };
};

useTodoSearch.tsx

export const useTodoSearch = (todos: Todo[], deleteTodo: (id: number) => void) => {
  const [searchKeyword, setSearchKeyword] = useState("");
  const [filteredTodos, setFilteredTodos] = useState<Todo[]>([]);

  useEffect(() => {
    const filtered = todos.filter((todo) =>
      todo.title.toLowerCase().startsWith(searchKeyword.toLowerCase())
    );
    setFilteredTodos(filtered);
  }, [searchKeyword, todos]);

  return {
    searchKeyword,
    setSearchKeyword,
    filteredTodos,
    onDelete: deleteTodo,
  };
};

カスタムフックの使用

作成したカスタムフックを呼び出す。

const App = () => {
  const { todos, newTodo, setNewTodo, addTodo, deleteTodo } = useTodoList();
  const { searchKeyword, setSearchKeyword, filteredTodos, onDelete } =
    useTodoSearch(todos, deleteTodo);

  return (
    <div className="container">
      {/* UIの部分 */}
    </div>
  );
};

これでカスタムフックを作成し、呼び出すことができました。

カスタムフック化のメリット

  1. コードの整理

    • ロジックとUIが分離され、コードが読みやすくなる
    • 各部分の役割が明確になる
  2. 再利用性

    • 同じロジックを他のコンポーネントでも使える
    • コードの重複を避けられる
  3. テストのしやすさ

    • ロジックが分離されているため、テストが書きやすい
    • UIとロジックを個別にテストできる

まとめ

カスタムフックの設計パターンを学び直す中で、以下のような価値を実感することができました。

  • コードの整理と保守性の向上
  • 再利用可能なロジックの実現
  • テストがしやすい設計

今回の記事を通じて、「どこまでロジックを分離すべきか」「どのような粒度でフックを作成するのが効果的か」という当初の問いに対する理解を深めることができました。
単にカスタムフックを作成するだけでなく、コンポーネントの責務分割やロジックの抽象化レベルなど、より良い設計を考えることの重要性を再認識しました。今後も、Reactのベストプラクティスについて、実装の詳細にこだわりながら理解を深めていきたいと思います。
「完璧な設計」を目指すのではなく、日々のコードレビューや実装の中で改善を積み重ねることで、より良いエンジニアになれると信じています。これからも学び続けることを大切にしていきます。

Discussion