160
186

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を使いこなすためのデザインパターン入門【図解解説】

160
Last updated at Posted at 2026-03-08

IMG_6948.jpg

はじめに

こんにちは、Watanabe Jin(@Sicut_study)です。
Reactを書いていると時々思うんです。

私はReactを活かしたコードをかけているのだろうか?

Reactにはデザインパターンとしていくつかの代表的なものが存在します。

  • HOC(高階コンポーネント) ※ 現在は不要
  • React Hooks
  • カスタム Hooks
  • Providerパターン
  • Container Presenterパターン
  • Render Props
  • Controlled Component
  • Extensible Styles
  • State Initializer
  • State Reducer
  • 条件レンダリング
  • Compound Component
    ...etc

どうでしょうか?
デザインパターンをみて実装のイメージがつくものは案外少ない人も多いかと思います。
React Hooksや条件レンダリングは割と利用します。

他のパターンは実装の機会も少ないので、そもそも触れる機会がないです。
もし他のパターンが最適な場合でも選択肢にならないでしょう。(知らないとできない)

ということで今回は絶対に知っておいてほしいReactデザインパターンを「タスク管理アプリ」を実装しながら学んでいきたいと思います。

Reactをさらに使いこなしたい!という人はぜひ最後までやってみてください!

動画教材も用意しています

こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材でわからない細かい箇所があれば動画も活用ください。

対象者

  • Reactを書いたことがある人
  • Reactをもっと活かしたコードを書きたい人
  • 設計スキルを高めたい人

Reactデザインパターンとは?

そもそもReactデザインパターンとは...

Reactアプリケーション開発で繰り返し発生する課題を解決するための、検証済みの設計手法やベストプラクティスの集まりのこと

デザインパターンを利用することで以下のメリットがあります。

image.png

代表的なデザインパターンを紹介します。
これらはReactを使う中で特に頻繁に用いられるプラクティスになります。

image.png

Reactデザインパターンは、ただ単にコードを書くための「型」ではなく、 アプリケーションをより構造的、保守的、かつ効率的に開発するための、開発者コミュニティによって蓄積された「設計ノウハウ集」 です。
現代のWeb開発では、React Hooksのような新しい機能の登場により、パターンも進化し続けています。

今回はこの中でも普段はあまり利用しないけど知っておいてほしいものをアプリを作りながら学んでいきます。

本チュートリアルの構成について
各セクションでは、前のセクションのパターンを削除して新しいパターンに置き換えていきます。これは学習のためにパターンの違いを明確に理解するための構成です。実務ではこれらのパターンは排他的ではなく、Provider + カスタムフック + Presenter のように組み合わせて使うことが多いです。

1. 環境構築: ベースアプリを作ろう

Node.jsのインストール(初めての方向け)

まずはNode.jsがインストールされているか確認しましょう。ターミナル(コマンドプロンプト)で以下のコマンドを実行してください。

node -v
npm -v

プロジェクトの作成

次に、Viteを使ってReact環境を作りましょう。

npm create vite@latest

Need to install the following packages:
create-vite@8.2.0
Ok to proceed? (y) y


> npx
> "create-vite"

│
◇  Project name:
│  react-design
│
◇  Select a framework:
│  React
│
◇  Select a variant:
│  TypeScript
│
◇  Use rolldown-vite (Experimental)?:
│  No
│
◇  Install with npm and start now?
│  Yes
│
◇  Scaffolding project in /home/jinwatanabe/workspace/qiit/react-design...
│
◇  Installing dependencies with npm...

added 240 packages, and audited 241 packages in 20s

48 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
│
◇  Starting dev server...

> react-design@0.0.0 dev
> vite


  VITE v7.2.4  ready in 446 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

http://localhost:5173を開くと以下の画面が表示されます。

image.png

必要なアイコンライブラリをインストールします。作成されたプロジェクトディレクトリに移動してから別のターミナルを開いて以下のコマンドを実行してください。

cd react-design
npm install react-icons

インストールが完了したら、以下のコマンドで確認できます。

npm list react-icons

以下のような表示が出ればインストール成功です。

react-design@0.0.0 C:\Users\ユーザー名\projects\react-design
└── react-icons@5.5.0

VSCodeを開いてコードを書いていきましょう。
このチュートリアルではReactの基本的な部分に関しての説明は省略します。

src/index.css
/* すべて消す */
src/App.css
/* src/App.css */
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  background-color: #f5f5f5;
}

.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
}

.app-header {
  margin-bottom: 2rem;
  text-align: center;
}

.app-header h1 {
  margin: 0;
  color: #333;
}

.tasks-container {
  background-color: white;
  border-radius: 8px;
  padding: 1.5rem;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.task-form {
  display: flex;
  margin-bottom: 1.5rem;
  gap: 0.5rem;
}

.task-form input {
  flex: 1;
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.task-form select {
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.task-form button {
  padding: 0.75rem 1rem;
  background-color: #3498db;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
}

.task-form button:hover {
  background-color: #2980b9;
}

.task-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.task-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.75rem 1rem;
  margin-bottom: 0.5rem;
  background-color: #f9f9f9;
  border-radius: 4px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}

.task-item.completed .task-title {
  text-decoration: line-through;
  color: #888;
}

.task-priority {
  font-size: 0.8rem;
  padding: 0.2rem 0.5rem;
  border-radius: 3px;
  color: white;
  margin-right: 0.5rem;
}

.priority-low {
  background-color: #27ae60;
}

.priority-medium {
  background-color: #f39c12;
}

.priority-high {
  background-color: #e74c3c;
}

.task-actions {
  display: flex;
  gap: 0.5rem;
}

.task-actions button {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 1rem;
  display: flex;
  align-items: center;
  padding: 0.25rem;
}

.toggle-btn {
  color: #27ae60;
}

.delete-btn {
  color: #e74c3c;
}

.empty-message {
  text-align: center;
  color: #888;
  font-style: italic;
}

次に、domainフォルダとTask.tsファイルを作成します。

mkdir src/domain
touch src/domain/Task.ts
src/domain/Task.ts
export interface Task {
  id: string;
  title: string;
  completed: boolean;
  priority: 'low' | 'medium' | 'high';
}
src/App.tsx
import React, { useState, useEffect } from "react";
import { FaCheck, FaTrash } from "react-icons/fa";
import "./App.css";
import type { Task } from "./domain/Task";

const App: React.FC = () => {
  const [tasks, setTasks] = useState<Task[]>(() => {
    const savedTasks = localStorage.getItem("tasks");
    return savedTasks ? JSON.parse(savedTasks) : [];
  });

  const [title, setTitle] = useState("");
  const [priority, setPriority] = useState<Task["priority"]>("medium");

  useEffect(() => {
    localStorage.setItem("tasks", JSON.stringify(tasks));
  }, [tasks]);

  const handleAddTask = (e: React.FormEvent) => {
    e.preventDefault();
    if (title.trim()) {
      const newTask: Task = {
        // 本番環境では crypto.randomUUID() などのより安全な方法を使用してください
        id: Date.now().toString(),
        title: title.trim(),
        completed: false,
        priority,
      };
      setTasks([...tasks, newTask]);
      setTitle("");
      setPriority("medium");
    }
  };

  const handleToggleTask = (id: string) => {
    setTasks(
      tasks.map((task) =>
        task.id === id ? { ...task, completed: !task.completed } : task
      )
    );
  };

  const handleDeleteTask = (id: string) => {
    setTasks(tasks.filter((task) => task.id !== id));
  };

  return (
    <div className="app">
      <header className="app-header">
        <h1>タスク管理アプリ</h1>
      </header>

      <div className="tasks-container">
        <form className="task-form" onSubmit={handleAddTask}>
          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="新しいタスクを追加"
          />
          <select
            value={priority}
            onChange={(e) => setPriority(e.target.value as Task["priority"])}
          >
            <option value="low"></option>
            <option value="medium"></option>
            <option value="high"></option>
          </select>
          <button type="submit">追加</button>
        </form>

        {tasks.length === 0 ? (
          <p className="empty-message">
            タスクがありません。新しいタスクを追加してください。
          </p>
        ) : (
          <ul className="task-list">
            {tasks.map((task) => (
              <li
                key={task.id}
                className={`task-item ${task.completed ? "completed" : ""}`}
              >
                <div>
                  <span className={`task-priority priority-${task.priority}`}>
                    {task.priority}
                  </span>
                  <span className="task-title">{task.title}</span>
                </div>
                <div className="task-actions">
                  <button
                    className="toggle-btn"
                    onClick={() => handleToggleTask(task.id)}
                    aria-label={
                      task.completed
                        ? "タスクを未完了にする"
                        : "タスクを完了する"
                    }
                  >
                    <FaCheck />
                  </button>
                  <button
                    className="delete-btn"
                    onClick={() => handleDeleteTask(task.id)}
                    aria-label="タスクを削除"
                  >
                    <FaTrash />
                  </button>
                </div>
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  );
};

export default App;

以下の画面が表示されればベースのアプリは完成です。

image.png

2. Container/Presenterパターン

まずはContainer/Presenterというデザインパターンを実装しましょう
Container/Presenterパターンは、コンポーネントを「見た目の表示を担当する部分」と「ロジックを担当する部分」に分離する設計パターンです。

image.png

Reactには大事な原則として純粋性という考え方があります。

image.png

Reactにおける純粋性とは、同じ入力(props・state・context)を受け取ったら常に同じ出力(UI)を返すという特性のことです。純粋なコンポーネントは、これらの入力が変わらない限り同じ結果をレンダリングします。

Container/Presenterパターンが良いのは関心の分離をすることで理解しやすいコードになると同時に純粋性を守ることができるようになることです。

それでは先程のアプリにContainer/Presenterを適応するようにリファクタリングを行います。

src/index.css
/* src/index.css */
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

* {
  box-sizing: border-box;
}

.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
}

.app-header {
  margin-bottom: 2rem;
  text-align: center;
}

.tasks-container {
  background-color: #f5f5f5;
  border-radius: 8px;
  padding: 1.5rem;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.task-form {
  display: flex;
  margin-bottom: 1.5rem;
  gap: 0.5rem;
}

.task-form input {
  flex: 1;
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.task-form select {
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.task-form button {
  padding: 0.75rem 1rem;
  background-color: #3498db;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
}

.task-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.task-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.75rem 1rem;
  margin-bottom: 0.5rem;
  background-color: white;
  border-radius: 4px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}

.task-item.completed .task-title {
  text-decoration: line-through;
  color: #888;
}

.task-priority {
  font-size: 0.8rem;
  padding: 0.2rem 0.5rem;
  border-radius: 3px;
  color: white;
  margin-right: 0.5rem;
}

.priority-low {
  background-color: #27ae60;
}

.priority-medium {
  background-color: #f39c12;
}

.priority-high {
  background-color: #e74c3c;
}

.task-actions {
  display: flex;
  gap: 0.5rem;
}

.task-actions button {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 1rem;
  display: flex;
  align-items: center;
  padding: 0.25rem;
}

.toggle-btn {
  color: #27ae60;
}

.delete-btn {
  color: #e74c3c;
}

.empty-message {
  text-align: center;
  color: #888;
  font-style: italic;
}
mkdir src/components
mkdir src/components/TaskList
touch src/components/TaskList/TaskListPresenter.tsx
touch src/components/TaskList/TaskListContainer.tsx

mkdir src/components/TaskForm
touch src/components/TaskForm/TaskFormPresenter.tsx
src/components/TaskList/TaskListPresenter.tsx
import React from "react";
import { FaCheck, FaTrash } from "react-icons/fa";
import type { Task } from "../../domain/Task";

interface TaskListPresenterProps {
  tasks: Task[];
  onToggleTask: (id: string) => void;
  onDeleteTask: (id: string) => void;
}

const TaskListPresenter: React.FC<TaskListPresenterProps> = ({
  tasks,
  onToggleTask,
  onDeleteTask,
}) => {
  if (tasks.length === 0) {
    return (
      <p className="empty-message">
        タスクがありません。新しいタスクを追加してください。
      </p>
    );
  }

  return (
    <ul className="task-list">
      {tasks.map((task) => (
        <li
          key={task.id}
          className={`task-item ${task.completed ? "completed" : ""}`}
        >
          <div>
            <span className={`task-priority priority-${task.priority}`}>
              {task.priority}
            </span>
            <span className="task-title">{task.title}</span>
          </div>
          <div className="task-actions">
            <button
              className="toggle-btn"
              onClick={() => onToggleTask(task.id)}
              aria-label={
                task.completed ? "タスクを未完了にする" : "タスクを完了する"
              }
            >
              <FaCheck />
            </button>
            <button
              className="delete-btn"
              onClick={() => onDeleteTask(task.id)}
              aria-label="タスクを削除"
            >
              <FaTrash />
            </button>
          </div>
        </li>
      ))}
    </ul>
  );
};

export default TaskListPresenter;
src/components/TaskForm/TaskFormPresenter.tsx
import React, { useState } from "react";
import type { Task } from "../../domain/Task";

interface TaskFormPresenterProps {
  onAddTask: (title: string, priority: Task["priority"]) => void;
}

// フォームの入力値(title・priority)はユーザー操作に直結するUIローカルな状態です。
// これはContainerが管理するアプリケーションのビジネスロジックとは異なるため、
// Presenterが内部で管理しています。
// 「Presenterはビジネスロジックを持たない」という原則は守られています。
const TaskFormPresenter: React.FC<TaskFormPresenterProps> = ({ onAddTask }) => {
  const [title, setTitle] = useState("");
  const [priority, setPriority] = useState<Task["priority"]>("medium");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (title.trim()) {
      onAddTask(title, priority);
      setTitle("");
      setPriority("medium");
    }
  };

  return (
    <form className="task-form" onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="新しいタスクを追加"
      />
      <select
        value={priority}
        onChange={(e) => setPriority(e.target.value as Task["priority"])}
      >
        <option value="low"></option>
        <option value="medium"></option>
        <option value="high"></option>
      </select>
      <button type="submit">追加</button>
    </form>
  );
};

export default TaskFormPresenter;
src/components/TaskList/TaskListContainer.tsx
import React, { useState, useEffect } from "react";
import TaskListPresenter from "./TaskListPresenter";
import type { Task } from "../../domain/Task";
import TaskFormPresenter from "../TaskForm/TaskFormPresenter";

const TaskListContainer: React.FC = () => {
  const [tasks, setTasks] = useState<Task[]>(() => {
    const savedTasks = localStorage.getItem("tasks");
    return savedTasks ? JSON.parse(savedTasks) : [];
  });

  useEffect(() => {
    localStorage.setItem("tasks", JSON.stringify(tasks));
  }, [tasks]);

  const handleToggleTask = (id: string) => {
    setTasks((prevTasks) =>
      prevTasks.map((task) =>
        task.id === id ? { ...task, completed: !task.completed } : task
      )
    );
  };

  const handleDeleteTask = (id: string) => {
    setTasks((prevTasks) => prevTasks.filter((task) => task.id !== id));
  };

  const handleAddTask = (title: string, priority: Task["priority"]) => {
    const newTask: Task = {
      // 本番環境では crypto.randomUUID() などのより安全な方法を使用してください
      id: Date.now().toString(),
      title,
      completed: false,
      priority,
    };

    setTasks((prevTasks) => [...prevTasks, newTask]);
  };

  return (
    <div className="tasks-container">
      <TaskFormPresenter onAddTask={handleAddTask} />
      <TaskListPresenter
        tasks={tasks}
        onToggleTask={handleToggleTask}
        onDeleteTask={handleDeleteTask}
      />
    </div>
  );
};

export default TaskListContainer;

PresenterではContainerから状態変更の関数とステートを受け取って表示だけを行っています。

      <TaskListPresenter
        tasks={tasks}
        onToggleTask={handleToggleTask}
        onDeleteTask={handleDeleteTask}
      />

アプリが正しく使えればリファクタリングができています。
こうすることで画面の表示に関する修正はPresenter、データに関して修正する場合はContainerを修正すればいいので関心の分離によりコードの認知負荷も軽減します。

Container/Presenterパターンのポイント

メリット

  • 関心の分離により、コードの認知負荷が下がる
  • Presenterはpropsだけに依存するため、単体テストが書きやすい(モックが少なくて済む)
  • UIの修正とロジックの修正が独立して行えるため、影響範囲が明確になる
  • デザイナーとエンジニアで作業を分担しやすくなる

デメリット

  • ファイル数が増え、小規模なコンポーネントではオーバーエンジニアリングになりがち
  • ContainerとPresenterの境界が曖昧になることがある(フォームのローカルstateなど)

向いているケース

  • ロジックが複雑でテストを書きたいとき
  • UIのバリエーションが複数必要なとき(同じロジックで見た目だけ変える)
  • チームで役割分担して開発するとき

3. カスタムフック

Container/PresenterパターンでロジックをContainerに分離しましたが、カスタムフックを利用するとさらに独立した関数として抽出でき、より柔軟な設計が可能になります。

image.png

カスタムフックはuseから始まる名前をつける必要があります。
それではContainer/Presenterパターンをやめてカスタムフックを利用しましょう

rm src/components/TaskList/TaskListContainer.tsx
rm src/components/TaskList/TaskListPresenter.tsx
rm src/components/TaskForm/TaskFormPresenter.tsx
touch src/components/TaskList.tsx
touch src/components/TaskForm.tsx
mkdir src/hooks
touch src/hooks/useTaskList.ts
src/components/TaskList.tsx
import React from "react";
import { FaCheck, FaTrash } from "react-icons/fa";
import type { Task } from "../domain/Task";

interface TaskListProps {
  tasks: Task[];
  onToggleTask: (id: string) => void;
  onDeleteTask: (id: string) => void;
}

const TaskList: React.FC<TaskListProps> = ({ tasks, onToggleTask, onDeleteTask }) => {
  if (tasks.length === 0) {
    return (
      <p className="empty-message">
        タスクがありません。新しいタスクを追加してください。
      </p>
    );
  }

  return (
    <ul className="task-list">
      {tasks.map((task) => (
        <li
          key={task.id}
          className={`task-item ${task.completed ? "completed" : ""}`}
        >
          <div>
            <span className={`task-priority priority-${task.priority}`}>
              {task.priority}
            </span>
            <span className="task-title">{task.title}</span>
          </div>
          <div className="task-actions">
            <button
              className="toggle-btn"
              onClick={() => onToggleTask(task.id)}
              aria-label={task.completed ? "タスクを未完了にする" : "タスクを完了する"}
            >
              <FaCheck />
            </button>
            <button
              className="delete-btn"
              onClick={() => onDeleteTask(task.id)}
              aria-label="タスクを削除"
            >
              <FaTrash />
            </button>
          </div>
        </li>
      ))}
    </ul>
  );
};

export default TaskList;
src/components/TaskForm.tsx
import React, { useState } from "react";
import type { Task } from "../domain/Task";

interface TaskFormProps {
  onAddTask: (title: string, priority: Task["priority"]) => void;
}

const TaskForm: React.FC<TaskFormProps> = ({ onAddTask }) => {
  const [title, setTitle] = useState("");
  const [priority, setPriority] = useState<Task["priority"]>("medium");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (title.trim()) {
      onAddTask(title, priority);
      setTitle("");
      setPriority("medium");
    }
  };

  return (
    <form className="task-form" onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="新しいタスクを追加"
      />
      <select
        value={priority}
        onChange={(e) => setPriority(e.target.value as Task["priority"])}
      >
        <option value="low"></option>
        <option value="medium"></option>
        <option value="high"></option>
      </select>
      <button type="submit">追加</button>
    </form>
  );
};

export default TaskForm;

コンポーネントはPresenterのときと変わりません。

src/hooks/useTaskList.ts
import { useState, useEffect } from "react";
import type { Task } from "../domain/Task";

export function useTaskList() {
  const [tasks, setTasks] = useState<Task[]>(() => {
    const savedTasks = localStorage.getItem("tasks");
    return savedTasks ? JSON.parse(savedTasks) : [];
  });

  useEffect(() => {
    localStorage.setItem("tasks", JSON.stringify(tasks));
  }, [tasks]);

  const addTask = (title: string, priority: Task["priority"]) => {
    const newTask: Task = {
      // 本番環境では crypto.randomUUID() などのより安全な方法を使用してください
      id: Date.now().toString(),
      title,
      completed: false,
      priority,
    };
    setTasks((prev) => [...prev, newTask]);
  };

  const toggleTask = (id: string) => {
    setTasks((prev) =>
      prev.map((task) =>
        task.id === id ? { ...task, completed: !task.completed } : task
      )
    );
  };

  const deleteTask = (id: string) => {
    setTasks((prev) => prev.filter((task) => task.id !== id));
  };

  return {
    tasks,
    addTask,
    toggleTask,
    deleteTask,
  };
}

Task操作に関するロジックをカスタムフックにまとめました。
カスタムフックから提供されるaddTaskdeleteTaskを使うことでtasksの状態を変更することができます。こうすることでロジックを意識せずにどのコンポーネントからでもタスクを更新することができます。

カスタムフックにすることで状態変更のロジックを分離することができました。
複数の箇所で状態変更がある場合にはロジックが分散することもなくなり修正が容易になります。

src/App.tsx
import React from "react";
import "./App.css";
import { useTaskList } from "./hooks/useTaskList";
import TaskList from "./components/TaskList";
import TaskForm from "./components/TaskForm";

const App: React.FC = () => {
  const { tasks, addTask, toggleTask, deleteTask } = useTaskList();

  return (
    <div className="app">
      <header className="app-header">
        <h1>タスク管理アプリ</h1>
      </header>
      <div className="tasks-container">
        <TaskForm onAddTask={addTask} />
        <TaskList
          tasks={tasks}
          onToggleTask={toggleTask}
          onDeleteTask={deleteTask}
        />
      </div>
    </div>
  );
};

export default App;

App.tsxではタスクのカスタムフックを呼び出してタスクの初期値と更新関数を受け取っています。
それらをPresenterの役割を果たすコンポーネントに渡しています。

const App: React.FC = () => {
  const { tasks, addTask, toggleTask, deleteTask } = useTaskList();

アプリが正しく動いているかを確認して次に進みましょう。

カスタムフックのポイント

メリット

  • ロジックをコンポーネントから完全に切り離せるため、再利用性が高い
  • フック単体でテストが書きやすい
  • 複数コンポーネントで同じロジックを使う場合にDRYを保てる

デメリット

  • フックの数が増えると依存関係が複雑になることがある
  • グローバルな状態を複数フックで管理するとpropsのバケツリレーが発生しうる(→ 次のProviderパターンで解決)

向いているケース

  • データ取得・加工・副作用などのロジックをコンポーネントから分離したいとき
  • 同じロジックを複数のコンポーネントで使い回したいとき

4. Providerパターン

カスタムフックを実装しましたが、仮に深くネストされたコンポーネントでカスタムフックの関数を実行したいとなった場合にPropsをバケツリレーのようにコンポーネントに渡し続けないといけません。

そこでProviderパターンを実装して、グローバルな状態管理を行えるようにします。
ReactのContext APIを使って実装され、深くネストされたコンポーネントに対してpropsのバケツリレーをせずにデータを渡せるようになります。

image.png

image.png

mkdir src/contexts
touch src/contexts/TaskContext.tsx
touch src/contexts/TaskProvider.tsx
rm src/hooks/useTaskList.ts

まずはContextから作成します。

src/contexts/TaskContext.tsx
import { createContext, useContext } from "react";
import type { Task } from "../domain/Task";

export interface TaskContextType {
  tasks: Task[];
  addTask: (title: string, priority: Task["priority"]) => void;
  toggleTask: (id: string) => void;
  deleteTask: (id: string) => void;
}

export const defaultTaskContext: TaskContextType = {
  tasks: [],
  addTask: () => {},
  toggleTask: () => {},
  deleteTask: () => {},
};

export const TaskContext = createContext<TaskContextType>(defaultTaskContext);

export function useTaskContext() {
  return useContext(TaskContext);
}

TaskContextでは、Contextで管理したいデータと関数の型定義を行っています。

TaskContextTypeインターフェースで、タスクのリスト(tasks)とタスクを操作する関数(addTask、toggleTask、deleteTask)を定義しています。

export interface TaskContextType {
  tasks: Task[];
  addTask: (title: string, priority: Task["priority"]) => void;
  toggleTask: (id: string) => void;
  deleteTask: (id: string) => void;
}

defaultTaskContextは、Contextの初期値として設定されます。これは、Providerの外でContextを使おうとした場合のフォールバック値であり、また型安全性を保つためにも必要です。関数は空の実装(何もしない関数)を設定しています。

export const defaultTaskContext: TaskContextType = {
  tasks: [],
  addTask: () => {},
  toggleTask: () => {},
  deleteTask: () => {},
};

createContextでContextオブジェクトを作成し、最後にuseTaskContextというカスタムフックを用意しています。これはuseContext(TaskContext)を毎回書く代わりに、より簡潔にuseTaskContext()と書けるようにするためのヘルパー関数です。

export function useTaskContext() {
  return useContext(TaskContext);
}

次に、TaskProviderを実装します。これが実際のロジックを持つコンポーネントになります。

src/contexts/TaskProvider.tsx
import React, { useState, useEffect } from "react";
import { TaskContext } from "./TaskContext";
import type { ReactNode } from "react";
import type { Task } from "../domain/Task";

interface TaskProviderProps {
  children: ReactNode;
}

export const TaskProvider: React.FC<TaskProviderProps> = ({ children }) => {
  const [tasks, setTasks] = useState<Task[]>(() => {
    const savedTasks = localStorage.getItem("tasks");
    return savedTasks ? JSON.parse(savedTasks) : [];
  });

  useEffect(() => {
    localStorage.setItem("tasks", JSON.stringify(tasks));
  }, [tasks]);

  const addTask = (title: string, priority: Task["priority"]) => {
    if (title.trim()) {
      const newTask: Task = {
        // 本番環境では crypto.randomUUID() などのより安全な方法を使用してください
        id: Date.now().toString(),
        title,
        priority,
        completed: false,
      };
      setTasks([...tasks, newTask]);
    }
  };

  const toggleTask = (id: string) => {
    setTasks(
      tasks.map((task) =>
        task.id === id ? { ...task, completed: !task.completed } : task
      )
    );
  };

  const deleteTask = (id: string) => {
    setTasks(tasks.filter((task) => task.id !== id));
  };

  const value = {
    tasks,
    addTask,
    toggleTask,
    deleteTask,
  };

  return <TaskContext.Provider value={value}>{children}</TaskContext.Provider>;
};

TaskProviderは、以前カスタムフックに書いていたロジック(useStateやタスク操作の関数)をすべて含んでいます。そして、それらの値をContext.Providerのvalue propとして渡すことで、配下のすべてのコンポーネントからアクセス可能にしています。

  return <TaskContext.Provider value={value}>{children}</TaskContext.Provider>;

childrenプロパティを受け取り、それをProviderで囲むことで、TaskProviderの中に配置されたすべてのコンポーネントがTaskContextにアクセスできるようになります。

それではAppコンポーネントから利用してみましょう

src/App.tsx
import React from "react";
import "./App.css";
import { TaskProvider } from "./contexts/TaskProvider";
import TaskList from "./components/TaskList";
import TaskForm from "./components/TaskForm";
import { useTaskContext } from "./contexts/TaskContext";

const App: React.FC = () => {
  return (
    <TaskProvider>
      <MainContent />
    </TaskProvider>
  );
};

const MainContent: React.FC = () => {
  const { tasks, addTask, toggleTask, deleteTask } = useTaskContext();
  return (
    <div className="app">
      <header className="app-header">
        <h1>タスク管理アプリ</h1>
      </header>
      <div className="tasks-container">
        <TaskForm onAddTask={addTask} />
        <TaskList
          tasks={tasks}
          onToggleTask={toggleTask}
          onDeleteTask={deleteTask}
        />
      </div>
    </div>
  );
};

export default App;

useTaskContext()TaskProviderを呼び出した後に使わないと、Contextの値が「default値」になり、状態が正しく連携されないので以下のような構造にしました。

const App: React.FC = () => {
  return (
    <TaskProvider>
      <MainContent />
    </TaskProvider>
  );
};

const MainContent: React.FC = () => {

ここまで同じように動いていれば次に進みましょう

Providerパターンのポイント

メリット

  • propsのバケツリレーを解消し、深くネストされたコンポーネントにも値を届けられる
  • アプリ全体で共有したい状態(認証情報・テーマ・言語設定など)の管理に最適

デメリット

  • Contextの値が変更されると、そのContextを使っているすべてのコンポーネントが再レンダリングされるため、パフォーマンスに注意が必要
  • 使いすぎるとコンポーネントの依存関係が見えにくくなる

向いているケース

  • 認証情報・テーマ・言語設定など、アプリ全体で共有する状態の管理
  • 深くネストされたコンポーネントツリーで状態を共有したいとき

5. Compound Component

次に、タスクカードのよりフレキシブルなレイアウトを実現するために、Compound Componentパターンを実装します。

Compound Componentパターンとは、複数の子コンポーネントが協調して動作する、柔軟で再利用可能なコンポーネントを作成するための設計パターンです。親コンポーネントと子コンポーネントが暗黙的に状態を共有し、まるで一つのコンポーネントのように機能します。

このパターンでは、複数のコンポーネントを組み合わせて一つの機能を実現します。HTMLの<select><option>の関係に似ており、それぞれは独立したコンポーネントですが、一緒に使うことで意味を持ちます。親コンポーネントが内部状態を管理し、子コンポーネントはその状態にアクセスして適切に動作します。

メリットデメリットの話をする前にイメージをつけるために実装から始めます。

mkdir -p src/components/TaskCard
touch src/components/TaskCard/TaskCard.tsx
touch src/components/TaskCard/TaskCardTitle.tsx
touch src/components/TaskCard/TaskCardPriority.tsx
touch src/components/TaskCard/TaskCardActions.tsx
touch src/components/TaskCard/TaskCardContext.tsx
src/App.css
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
    Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  background-color: #f5f5f5;
}

.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
}

.app-header {
  margin-bottom: 2rem;
  text-align: center;
}

.app-header h1 {
  margin: 0;
  color: #333;
}

.tasks-container {
  background-color: white;
  border-radius: 8px;
  padding: 1.5rem;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.task-form {
  display: flex;
  margin-bottom: 1.5rem;
  gap: 0.5rem;
}

.task-form input {
  flex: 1;
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.task-form select {
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.task-form button {
  padding: 0.75rem 1rem;
  background-color: #3498db;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
}

.task-form button:hover {
  background-color: #2980b9;
}

.task-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.task-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.75rem 1rem;
  margin-bottom: 0.5rem;
  background-color: #f9f9f9;
  border-radius: 4px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}

.task-item.completed .task-title {
  text-decoration: line-through;
  color: #888;
}

.task-priority {
  font-size: 0.8rem;
  padding: 0.2rem 0.5rem;
  border-radius: 3px;
  color: white;
  margin-right: 0.5rem;
}

.priority-low {
  background-color: #27ae60;
}

.priority-medium {
  background-color: #f39c12;
}

.priority-high {
  background-color: #e74c3c;
}

.task-actions {
  display: flex;
  gap: 0.5rem;
}

.task-actions button {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 1rem;
  display: flex;
  align-items: center;
  padding: 0.25rem;
}

.toggle-btn {
  color: #27ae60;
}

.delete-btn {
  color: #e74c3c;
}

.empty-message {
  text-align: center;
  color: #888;
  font-style: italic;
}

/* 追加 */
.task-card {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
}

Compound Componentにする関係で同じスタイルにするためにcssを追加しています。

src/components/TaskCard/TaskCardContext.tsx
import { createContext, useContext } from "react";
import type { Task } from "../../domain/Task";

export interface TaskCardContextProps {
  task: Task;
  onToggle: (id: string) => void;
  onDelete: (id: string) => void;
}

export const TaskCardContext = createContext<TaskCardContextProps | undefined>(undefined);

export function useTaskCardContext() {
  const ctx = useContext(TaskCardContext);
  if (!ctx) throw new Error("TaskCard compound components must be used within <TaskCard>");
  return ctx;
}

Contextを利用してこれから利用する子コンポーネントでカードそれぞれの値をグローバルな値として使えるように用意しました。

次にそれぞれタスクカードを構成するパーツを作成します。

src/components/TaskCard/TaskCardTitle.tsx
import React from "react";
import { useTaskCardContext } from "./TaskCardContext";

export const TaskCardTitle: React.FC = () => {
  const { task } = useTaskCardContext();
  return (
    <>
      <span className="task-title">{task.title}</span>
    </>
  );
};
src/components/TaskCard/TaskCardPriority.tsx
import React from "react";
import { useTaskCardContext } from "./TaskCardContext";

export const TaskCardPriority: React.FC = () => {
  const { task } = useTaskCardContext();
  return (
    <span className={`task-priority priority-${task.priority}`}>{task.priority}</span>
  );
};
src/components/TaskCard/TaskCardActions.tsx
import React from "react";
import { FaCheck, FaTrash } from "react-icons/fa";
import { useTaskCardContext } from "./TaskCardContext";

export const TaskCardActions: React.FC = () => {
  const { task, onToggle, onDelete } = useTaskCardContext();
  return (
    <div className="task-actions">
      <button
        className="toggle-btn"
        onClick={() => onToggle(task.id)}
        aria-label={
          task.completed ? "タスクを未完了にする" : "タスクを完了する"
        }
      >
        <FaCheck />
      </button>
      <button
        className="delete-btn"
        onClick={() => onDelete(task.id)}
        aria-label="タスクを削除"
      >
        <FaTrash />
      </button>
    </div>
  );
};

TaskCardBaseコンポーネントは、Contextのプロバイダーとして機能します。受け取ったprops(task、onToggle、onDelete)をContextの値として提供し、childrenをレンダリングします。これにより、TaskCardの内部に配置された任意の子コンポーネントがこれらの値にアクセスできるようになります。

src/components/TaskCard/TaskCard.tsx
import React from "react";
import type { ReactNode } from "react";
import type { Task } from "../../domain/Task";
import { TaskCardContext } from "./TaskCardContext";
import { TaskCardTitle } from "./TaskCardTitle";
import { TaskCardActions } from "./TaskCardActions";
import { TaskCardPriority } from "./TaskCardPriority";

interface TaskCardProps {
  task: Task;
  onToggle: (id: string) => void;
  onDelete: (id: string) => void;
  children: ReactNode;
}

const TaskCardBase: React.FC<TaskCardProps> = ({ task, onToggle, onDelete, children }) => {
  return (
    <TaskCardContext.Provider value={{ task, onToggle, onDelete }}>
      <div className={`task-card${task.completed ? " completed" : ""}`}>
        {children}
      </div>
    </TaskCardContext.Provider>
  );
};

export const TaskCard = Object.assign(TaskCardBase, {
  Title: TaskCardTitle,
  Actions: TaskCardActions,
  Priority: TaskCardPriority,
});

重要なのは最後の部分で、Object.assignを使ってTaskCardBaseに子コンポーネントを静的プロパティとして追加しています。これにより、TaskCard.TitleやTaskCard.Actionsという形式で子コンポーネントにアクセスできるようになります。この手法により、関連するコンポーネントが一つの名前空間にまとまり、使う側にとって直感的なAPIになります。

export const TaskCard = Object.assign(TaskCardBase, {
  Title: TaskCardTitle,
  Actions: TaskCardActions,
  Priority: TaskCardPriority,
});

それでは利用してみましょう

src/components/TaskList.tsx
import React from "react";
import type { Task } from "../domain/Task";
import { TaskCard } from "./TaskCard/TaskCard";

interface TaskListProps {
  tasks: Task[];
  onToggleTask: (id: string) => void;
  onDeleteTask: (id: string) => void;
}

const TaskList: React.FC<TaskListProps> = ({
  tasks,
  onToggleTask,
  onDeleteTask,
}) => {
  if (tasks.length === 0) {
    return (
      <p className="empty-message">
        タスクがありません。新しいタスクを追加してください。
      </p>
    );
  }

  return (
    <ul className="task-list">
      {tasks.map((task) => (
        <li
          key={task.id}
          className={`task-item${task.completed ? " completed" : ""}`}
        >
          <TaskCard task={task} onToggle={onToggleTask} onDelete={onDeleteTask}>
            <TaskCard.Priority />
            <TaskCard.Title />
            <TaskCard.Actions />
          </TaskCard>
        </li>
      ))}
    </ul>
  );
};

export default TaskList;

TaskCardコンポーネントに渡したTaskがTaskCardTitleとTaskCardActionsの内部で利用されているためこのように書くことが可能です。

          <TaskCard task={task} onToggle={onToggleTask} onDelete={onDeleteTask}>
            <TaskCard.Priority />
            <TaskCard.Title />
            <TaskCard.Actions />
          </TaskCard>

Compound Componentパターンの最大の利点は、柔軟性と使いやすさの両立です。利用者はコンポーネントの順序を自由に変更したり、必要な部分だけを使ったりできます。例えば、Priorityをなくすことも簡単です。

          <TaskCard task={task} onToggle={onToggleTask} onDelete={onDeleteTask}>
            <TaskCard.Title />
            <TaskCard.Actions />
          </TaskCard>

また、状態管理がカプセル化されているため、親コンポーネント(TaskCard)が内部状態を管理し、子コンポーネントはContextを通じてそれにアクセスするだけです。利用者側はpropsで複雑な状態を渡す必要がなく、宣言的にUIを構築できます。

またコンポーネントと違ってContextを使って暗黙的にPropsを受け取れます。
柔軟なUIを構築したいときのデザインパターンです。

動くことを確認して最後のデザインパターンに進みましょう

Compound Componentのポイント

メリット

  • 利用者がコンポーネントの構成要素を自由に組み合わせられるため、UIの柔軟性が高い
  • 子コンポーネントへのprops受け渡しがContextで隠蔽されるため、呼び出し側がシンプルになる
  • 関連するコンポーネントが一つの名前空間(TaskCard.Title など)にまとまり、可読性が上がる

デメリット

  • Contextを使うため、Provider外で使うとエラーになる(ただし適切なエラーメッセージで対処可能)
  • シンプルなカードにはオーバーエンジニアリングになりがち

向いているケース

  • タブ・アコーディオン・モーダルなど、複数のパーツが連携するUIコンポーネント
  • 同じデータを共有しながら、レイアウトや構成を利用側で柔軟に変えたいとき

6. State Reducer

最後に紹介するのはState Reducerパターンです。
State ReducerパターンはKent C. Dodds氏が考案したパターンで、コンポーネントやカスタムフックの内部状態の更新ロジックを、外部から制御できるようにする設計パターンです。

State Reducerパターンの核心は 制御の反転(Inversion of Control) という考え方にあります。

通常、カスタムフックやコンポーネントは内部で状態の更新ロジックを持っています。しかし、利用者が「特定の条件下では状態の更新を防ぎたい」「状態の更新時に追加の処理を挟みたい」といった要件を持つことがあります。
State Reducerパターンでは、状態更新のロジックを外部から渡せるようにすることで、フックの振る舞いをカスタマイズ可能にします。

touch src/hooks/useTaskReducer.ts

今回は「完了操作の回数制限」というカスタムロジックを、reducerの外のuseStateではなくTaskState内で管理することで、状態を一箇所に集約します。これにより、reducerが状態の唯一の真実の源(Single Source of Truth)になります。

src/hooks/useTaskReducer.ts
import { useReducer, useEffect } from "react";
import type { Task } from "../domain/Task";

export const taskActionTypes = {
  ADD: "ADD",
  TOGGLE: "TOGGLE",
  DELETE: "DELETE",
} as const;

export type TaskAction =
  | {
      type: typeof taskActionTypes.ADD;
      payload: { title: string; priority: Task["priority"] };
    }
  | { type: typeof taskActionTypes.TOGGLE; payload: { id: string } }
  | { type: typeof taskActionTypes.DELETE; payload: { id: string } };

export interface TaskState {
  tasks: Task[];
  completionCount: number;
}

export function taskReducer(state: TaskState, action: TaskAction): TaskState {
  switch (action.type) {
    case taskActionTypes.ADD: {
      const newTask: Task = {
        // 本番環境では crypto.randomUUID() などのより安全な方法を使用してください
        id: Date.now().toString(),
        title: action.payload.title,
        completed: false,
        priority: action.payload.priority,
      };
      return { ...state, tasks: [...state.tasks, newTask] };
    }
    case taskActionTypes.TOGGLE: {
      const target = state.tasks.find((t) => t.id === action.payload.id);
      const isCompleting = target && !target.completed;
      return {
        tasks: state.tasks.map((task) =>
          task.id === action.payload.id
            ? { ...task, completed: !task.completed }
            : task
        ),
        completionCount: isCompleting
          ? state.completionCount + 1
          : state.completionCount,
      };
    }
    case taskActionTypes.DELETE: {
      return {
        ...state,
        tasks: state.tasks.filter((task) => task.id !== action.payload.id),
      };
    }
    default: {
      throw new Error(`Unhandled action type`);
    }
  }
}

interface UseTaskReducerOptions {
  reducer?: (state: TaskState, action: TaskAction) => TaskState;
  initialTasks?: Task[];
}

export function useTaskReducer({
  reducer = taskReducer,
  initialTasks = [],
}: UseTaskReducerOptions = {}) {
  const [state, dispatch] = useReducer(reducer, {
    tasks: initialTasks,
    completionCount: 0,
  });

  useEffect(() => {
    localStorage.setItem("tasks", JSON.stringify(state.tasks));
  }, [state.tasks]);

  const addTask = (title: string, priority: Task["priority"]) => {
    dispatch({ type: taskActionTypes.ADD, payload: { title, priority } });
  };

  const toggleTask = (id: string) => {
    dispatch({ type: taskActionTypes.TOGGLE, payload: { id } });
  };

  const deleteTask = (id: string) => {
    dispatch({ type: taskActionTypes.DELETE, payload: { id } });
  };

  return {
    tasks: state.tasks,
    completionCount: state.completionCount,
    addTask,
    toggleTask,
    deleteTask,
  };
}

今回は「追加」「完了」「削除」の挙動を外側から渡せるように修正をしていきます。
State Reducerパターンではまずはじめにアクションを定義するところから始めます。

export const taskActionTypes = {
  ADD: "ADD",
  TOGGLE: "TOGGLE",
  DELETE: "DELETE",
} as const;

次にそれぞれのアクションに対応する型を定義します。

export type TaskAction =
  | {
      type: typeof taskActionTypes.ADD;
      payload: { title: string; priority: Task["priority"] };
    }
  | { type: typeof taskActionTypes.TOGGLE; payload: { id: string } }
  | { type: typeof taskActionTypes.DELETE; payload: { id: string } };

このように、各アクションにはtypeとpayloadを持たせています。payloadにはそのアクションに必要なデータを含めます。payloadは、アクションと一緒に送られるデータのことです。

例えば追加アクションはpayloadとしてタイトル(string)とpriority(low,medium,high)を渡すという定義をしています。

{
      type: typeof taskActionTypes.ADD;
      payload: { title: string; priority: Task["priority"] };
}

次に、状態の型を定義します。今回は完了操作の回数(completionCount)もreducer内で一元管理します。

export interface TaskState {
  tasks: Task[];
  completionCount: number;
}

そして、デフォルトのreducerを実装します。このreducerが各アクションに応じた状態の更新ロジックを定義しています。TOGGLEアクションでは、タスクを完了に変えた場合にcompletionCountも同時にインクリメントします。

case taskActionTypes.TOGGLE: {
  const target = state.tasks.find((t) => t.id === action.payload.id);
  const isCompleting = target && !target.completed;
  return {
    tasks: state.tasks.map((task) =>
      task.id === action.payload.id
        ? { ...task, completed: !task.completed }
        : task
    ),
    completionCount: isCompleting
      ? state.completionCount + 1
      : state.completionCount,
  };
}

ここまでが基本的なreducerの実装です。重要なのは次の部分、State Reducerパターンの核となる部分です。

interface UseTaskReducerOptions {
  reducer?: (state: TaskState, action: TaskAction) => TaskState;
  initialTasks?: Task[];
}

export function useTaskReducer({
  reducer = taskReducer,
  initialTasks = [],
}: UseTaskReducerOptions = {}) {
  const [state, dispatch] = useReducer(reducer, {
    tasks: initialTasks,
    completionCount: 0,
  });

useTaskReducerフックは、オプションとしてreducerを受け取ることができます。渡されない場合はデフォルトのtaskReducerが使用されます。これが制御の反転の仕組みです。

利用者は独自のreducerを渡すことで、状態更新のロジックを完全にカスタマイズできます。それでは、このフックを使ってカスタマイズした「タスク完了操作は4回までしかできない」Reducerを作って適応してみましょう。

src/App.tsx
import React from "react";
import "./App.css";
import TaskList from "./components/TaskList";
import TaskForm from "./components/TaskForm";
import {
  useTaskReducer,
  taskReducer,
  taskActionTypes,
} from "./hooks/useTaskReducer";
import type { TaskState, TaskAction } from "./hooks/useTaskReducer";
import type { Task } from "./domain/Task";

const App: React.FC = () => {
  const initialTasks: Task[] = (() => {
    const savedTasks = localStorage.getItem("tasks");
    return savedTasks ? JSON.parse(savedTasks) : [];
  })();

  const { tasks, completionCount, addTask, toggleTask, deleteTask } = useTaskReducer({
    initialTasks,
    reducer(currentState: TaskState, action: TaskAction): TaskState {
      const changes = taskReducer(currentState, action);

      // completionCountはreducer内で管理されているため、
      // changesから取得した値で判断します
      if (changes.completionCount >= 4 && action.type === taskActionTypes.TOGGLE) {
        // 完了への変更のみブロック(未完了への変更は許可)
        const target = currentState.tasks.find((t) => t.id === action.payload.id);
        if (target && !target.completed) {
          return currentState;
        }
      }

      return changes;
    },
  });

  const tooManyCompletions = completionCount >= 4;

  return (
    <div className="app">
      <header className="app-header">
        <h1>タスク管理アプリ</h1>
        {tooManyCompletions && (
          <div
            style={{
              color: "#e74c3c",
              marginTop: "0.5rem",
              fontSize: "0.9rem",
              fontWeight: "bold",
            }}
          >
            完了操作は4回までです
          </div>
        )}
      </header>
      <div className="tasks-container">
        <TaskForm onAddTask={addTask} />
        <TaskList
          tasks={tasks}
          onToggleTask={toggleTask}
          onDeleteTask={deleteTask}
        />
      </div>
    </div>
  );
};

export default App;

4回までしか操作できない実装をしました。
まずはじめにデフォルトのreducerを計算します。

reducer(currentState: TaskState, action: TaskAction): TaskState {
  const changes = taskReducer(currentState, action);

そのあと4回以上完了ボタンが押されたかをreducer内のcompletionCountで確認します。
もし4回以上かつ新たに完了にしようとしている場合は、ステート変更前の値(currentState)を返します。

  if (changes.completionCount >= 4 && action.type === taskActionTypes.TOGGLE) {
    const target = currentState.tasks.find((t) => t.id === action.payload.id);
    if (target && !target.completed) {
      return currentState;  // 状態を変更しない
    }
  }

  // それ以外は通常通り変更を適用
  return changes;

実際にボタンを押すとこのようなバリデーションが表示されます。

image.png

State Reducerのポイント

メリット

  • フックの振る舞いを外部からカスタマイズできる「制御の反転」を実現できる
  • 状態とロジックがreducerに集約されるため、予測しやすく、テストしやすい
  • デフォルトのreducerをベースに部分的なカスタマイズが可能で、拡張性が高い

デメリット

  • reducerとアクションの定義が増えるため、シンプルなケースではコードが冗長になる
  • 概念の学習コストがやや高い

向いているケース

  • ライブラリやコンポーネントの挙動を利用側でカスタマイズさせたいとき
  • 状態遷移が複雑で、ロジックを一箇所に集約したいとき
  • 「この条件のときだけ状態の更新を防ぎたい」など、拡張性のある設計が必要なとき

おわりに

いかがでしたでしょうか?
Reactデザインパターンを利用することでより理解しやすいコードになったり、状態管理を綺麗にすることができるようになりました。

設計力を上げることはエンジニアとしての市場価値を上げる上でも重要なので、ぜひ活用してみて下さい。

詳しく解説した動画を投稿しているのでよかったらみてみてください!

JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください!
▼▼▼

図解ハンズオンたくさん投稿しています!

本チュートリアルのレビュアーの皆様

次回のハンズオンのレビュアーはXにて募集します。

  • tokec様
  • ナツキ様
  • 野沢和広様
  • ARISA様
  • 川野辺旭様
  • soma様
  • k-kamijima様
  • kazu様
160
186
1

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
160
186

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?