6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

疎結合なReactコンポーネントを構築する(Step1:Custom Hooksによる責務の分割)

6
Posted at

はじめに

Reactの実装は、最初責務やディレクトリ構成を意識しなくてもすんなり書いていけるのですが、プロジェクトの規模が大きくなりコードが増えていくと、次第にメンテナンスがしにくくなります。

「どんなディレクトリ構成がいいのか?」「責務はどのように分けていけばいいのか?」
これらは多くの開発者が直面する課題です。本シリーズでは、フロントエンドを単なる「見た目の層」ではなく、一つの「堅牢なアプリケーション」として設計する手法を学んでいこうと思います。

今回はその第一歩として、「CCustom Hooksによる責務の分割」を解説・実装し、コンポーネントの疎結合化を目指します。


【解説】そもそもReactはどんなもの??

1. 命令型から宣言型へのパラダイムシフト

Reactを深く理解するために避けて通れないのが、命令型(Imperative)宣言型(Declarative)という考え方の違いです。

特にバックエンドの実装経験が豊富なエンジニアほど、UIの変化を「ボタンが押されたら、スピナを表示して、その次に入力を無効化する」といった一連のイベント駆動な「手順」として実装しがちです。実は私も、Reactを書き始めた当初はこの「手順」の思考から抜け出すのに苦労しました💦💦💦

しかし、Reactの本質はその逆、目的地を伝えるだけの「宣言型」にあります。

React公式ドキュメントでは、この違いをタクシーに例えて非常にわかりやすく説明しています。

image.png

image.png

  • 命令型: 運転手に「次の角を右に曲がって、その次は左、3つ目の信号を越えたら……」と一歩ずつ指示を出す(隣に乗っている人の負荷が高い)。
  • 宣言型(React): 運転手に「目的地」を伝えるだけ。どう走るかは運転手(React)に任せる。

バックエンドのロジックが「処理の流れ」を設計するものであるのに対し、Reactの設計は「その瞬間のデータの断面図(状態)がこれなら、画面はどうあるべきか」という変換ルールを定義することに集中します。

2. UIは「スナップショット」である

この「目的地を伝えるだけ」という仕組みを支えている概念が、スナップショットとしての state です。

Reactにおいて、コンポーネントという「関数」を呼び出すことは、その瞬間のデータの状態を切り取った一枚の静止画(スナップショット)を得ることに他なりません。

公式の視点:
「関数から返される JSX は、その時点の UI のスナップショットのようなものです。その props、イベントハンドラ、ローカル変数はすべて、レンダー時における state を使用して計算されます。」
—— 出典:スナップショットとしての state

この考え方にシフトすると、設計の焦点は「UIをどう動かすか」から、「状態をどう定義し、どう分離するか」へと移ります。

【設計】State management with hooks

ここまで見てきた「宣言的な設計」を、実際の物理的なファイル構成に落とし込むために、マーチン・ファウラー氏が提唱する「State management with hooks」という構造を採用します。

以下の図は、これから実装するアプリケーションの全体像です。

Custom Hook(脳): API通信やビジネスロジックを閉じ込めます。UIの見た目については一切知りません。
Container(接着剤): 脳からデータを受け取り、体に流し込む「仲介役」です。
Presentational Component(体): 渡されたデータをどう描画するかだけに集中します。


実装

では、実際に手を動かしながら学んでいこうと思います。

成果物

Step1.gif

ディレクトリ構成
~/develop/react/my-react-design$ tree src
src
├── App.tsx
├── components
│   ├── UserList.tsx
│   └── UserSearch.tsx
├── hooks
│   └── useUserSearch.ts
└── main.tsx

2 directories, 5 files

設計

src/App.tsx
import { UserSearch } from "./components/UserSearch";

function App() {
  return (
    <div className="App">
      {/* App.tsxは全体のレイアウトや、将来的なルーティング(React Router等)を受け持ちます。
        具体的なビジネスロジックや検索の仕組みは、すべて下のUserSearchの中に隠蔽されています。
      */}
      <header style={{ backgroundColor: "#282c34", padding: "20px", color: "white", textAlign: "center" }}>
        <h1>React Architecture Study</h1>
      </header>

      <main style={{ padding: "20px" }}>
        <UserSearch />
      </main>
    </div>
  );
}

export default App;

src/components/UserList.tsx
type User = {
  id: number;
  name: string;
};

type Props = {
  users: User[];
  query: string;
  onQueryChange: (val: string) => void;
  loading: boolean;
};

export const UserList = ({ users, query, onQueryChange, loading }: Props) => {
  if (loading) return <p>読み込み中...</p>;

  return (
    <div style={{ padding: "20px" }}>
      <input
        type="text"
        value={query}
        placeholder="名前で検索..."
        onChange={(e) => onQueryChange(e.target.value)}
        style={{ marginBottom: "20px", padding: "8px", width: "300px" }}
      />
      <ul style={{ listStyle: "none", padding: 0 }}>
        {users.map((user) => (
          <li key={user.id} style={{ padding: "10px", borderBottom: "1px solid #eee" }}>
            <strong>{user.name}</strong>
          </li>
        ))}
      </ul>
      {users.length === 0 && <p>該当するユーザーがいません</p>}
    </div>
  );
};
src/components/UserSearch.tsx
import { useUserSearch } from "../hooks/useUserSearch";
import { UserList } from "./UserList";

export const UserSearch = () => {
  // ロジック(知能)を呼び出す
  const { query, setQuery, filteredUsers, loading } = useUserSearch();

  // 見た目(体)に流し込む
  return (
    <div style={{ maxWidth: "500px", margin: "0 auto" }}>
      <h1>ユーザー検索Step 1</h1>
      <UserList
        users={filteredUsers}
        query={query}
        onQueryChange={setQuery}
        loading={loading}
      />
    </div>
  );
};
src/hooks/useUserSearch.ts
import { useState, useEffect } from "react";

// APIから返ってくるデータの型定義
type User = {
  id: number;
  name: string;
  email: string;
};

export const useUserSearch = () => {
  const [users, setUsers] = useState<User[]>([]); // 全データ
  const [query, setQuery] = useState("");         // 検索窓の入力値
  const [loading, setLoading] = useState(true);   // ローディング状態

  // コンポーネントのマウント時に1回だけ実行
  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/users")
      .then((res) => res.json())
      .then((data) => {
        setUsers(data);
        setLoading(false);
      });
  }, []);

  // データの導出(Derived State)
  // usersが変わるか、queryが変わるたびに自動で再計算される
  const filteredUsers = users.filter((user) =>
    user.name.toLowerCase().includes(query.toLowerCase())
  );

  return {
    query,
    setQuery,
    filteredUsers,
    loading,
  };
};

補足:なぜこの「分け方」が重要なのか

バックエンドエンジニアやQAの視点で見ると、この「物理的な分離」には以下の3つの大きなメリットがあります。

  1. 「脳」と「体」の分離による認知負荷の低減
    useUserSearch は「どう計算するか(脳)」に、UserList は「どう表示するか(体)」に専念しています。修正が必要な際、「計算ロジックならHook、表示崩れならComponent」と迷わず辿り着けます。
  2. 副作用の局所化(不整合の防止)
    フィルタリング結果(filteredUsers)を別のStateにするのではなく、レンダリング時に「導出」しています。これにより、検索文字と表示結果がズレるという、命令型で起きがちなバグを構造的に防いでいます。
  3. 単体テストのしやすさ
    ロジックがCustom Hookに隔離されているため、UIの複雑なDOMを操作することなく、データ取得やフィルタリングのロジックをテストしやすくなります。

まとめ

設計の第一歩は、関心事をバラバラにして、それぞれを独立させることです。
まずはコンポーネントというタクシーに乗り込み、Reactという運転手に目的地を伝えるための「変換ルール」を綺麗に書き出してみましょう。

参考文献

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?