本文へスキップ
useReducer

Part 4 · 状態 (state)

31 / 64

useReducer

更新のしかたが増えてきたら、1 か所にまとめる

useState は、状態が 1 つか 2 つのうちは快適です。

ですが数が増え、「リセット」「下書きを読み込む」のようにまとめて変える操作が出てくると、 更新のコードが画面のあちこちに散らばりはじめます。

useReducer は、その散らばりを1 か所に集めるための道具です。

散らばっていく様子を見る

名前・メール・同意チェックの 3 つを持つフォームです。useState を 3 つ並べて作ってあります。

useState を 3 つ並べたフォーム

動く。ただし更新の手順が増えていく

動きます。問題は「リセット」です。

const reset = () => {
  setName("");
  setEmail("");
  setAgreed(false);
};

項目を 1 つ増やすたびに、ここも 1 行増えます。そして書き忘れても、React は何も教えてくれません。 画面上はただ「リセットしたのに 1 つだけ残る」という不具合になります。

state が 3 つなら耐えられます。 実際のフォームで 8 個、10 個になったところを想像してください。

scattered.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

export function Scattered() {
  useTrackDemoRender();

  // 1 つのフォームなのに、state が 3 つに散らばっている
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [agreed, setAgreed] = useState(false);

  // リセットするには、3 つとも書く必要がある
  const reset = () => {
    setName("");
    setEmail("");
    setAgreed(false);
  };

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="名前"
        aria-label="名前"
        value={name}
        onChange={(event) => setName(event.target.value)}
      />
      <Input
        placeholder="メールアドレス"
        aria-label="メールアドレス"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
      />

      <label className="flex items-center gap-2">
        <input
          type="checkbox"
          checked={agreed}
          onChange={(event) => setAgreed(event.target.checked)}
        />
        規約に同意する
      </label>

      <Button size="sm" variant="outline" onClick={reset}>
        リセット
      </Button>
    </div>
  );
}

まず、状態を 1 つにまとめる

バラバラの useState をやめて、1 つのオブジェクトにします。

type Form = {
  name: string;
  email: string;
  agreed: boolean;
};

こうすると「フォームの状態」がコード上の 1 か所に現れます。 項目を足すのも、この型に 1 行足すだけです。

with-reducer.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useReducer } from "react";

// 状態のかたち。1 つのオブジェクトにまとめる
type Form = {
  name: string;
  email: string;
  agreed: boolean;
};

// 「何が起きたか」の一覧。更新のしかたではなく、出来事を書く
type Action =
  | { type: "changed_name"; value: string }
  | { type: "changed_email"; value: string }
  | { type: "toggled_agreement" }
  | { type: "reset" };

const initialForm: Form = {
  name: "",
  email: "",
  agreed: false,
};

// 出来事を受け取って、次の状態を返す関数。ここが更新の一覧表になる
function formReducer(state: Form, action: Action): Form {
  switch (action.type) {
    case "changed_name":
      return { ...state, name: action.value };
    case "changed_email":
      return { ...state, email: action.value };
    case "toggled_agreement":
      return { ...state, agreed: !state.agreed };
    case "reset":
      return initialForm;
  }
}

export function WithReducer() {
  useTrackDemoRender();

  const [form, dispatch] = useReducer(formReducer, initialForm);

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="名前"
        aria-label="名前"
        value={form.name}
        onChange={(event) =>
          dispatch({ type: "changed_name", value: event.target.value })
        }
      />
      <Input
        placeholder="メールアドレス"
        aria-label="メールアドレス"
        value={form.email}
        onChange={(event) =>
          dispatch({ type: "changed_email", value: event.target.value })
        }
      />

      <label className="flex items-center gap-2">
        <input
          type="checkbox"
          checked={form.agreed}
          onChange={() => dispatch({ type: "toggled_agreement" })}
        />
        規約に同意する
      </label>

      {/* リセットは 1 行。中身が増えても、ここは変わらない */}
      <Button
        size="sm"
        variant="outline"
        onClick={() => dispatch({ type: "reset" })}
      >
        リセット
      </Button>
    </div>
  );
}

次に、「起きたこと」を並べる

ここが useReducer のいちばん大事な考え方です。

この違いが効いてきます。「何が起きたか」で書くと、1 つの出来事で複数の値をまとめて変えられるからです。

たとえば reset は「リセットが押された」という1 つの出来事です。useState 版ではこれが 3 回の更新に分解されていました。 出来事として書けば、分解する必要がありません。

with-reducer.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useReducer } from "react";

// 状態のかたち。1 つのオブジェクトにまとめる
type Form = {
  name: string;
  email: string;
  agreed: boolean;
};

// 「何が起きたか」の一覧。更新のしかたではなく、出来事を書く
type Action =
  | { type: "changed_name"; value: string }
  | { type: "changed_email"; value: string }
  | { type: "toggled_agreement" }
  | { type: "reset" };

const initialForm: Form = {
  name: "",
  email: "",
  agreed: false,
};

// 出来事を受け取って、次の状態を返す関数。ここが更新の一覧表になる
function formReducer(state: Form, action: Action): Form {
  switch (action.type) {
    case "changed_name":
      return { ...state, name: action.value };
    case "changed_email":
      return { ...state, email: action.value };
    case "toggled_agreement":
      return { ...state, agreed: !state.agreed };
    case "reset":
      return initialForm;
  }
}

export function WithReducer() {
  useTrackDemoRender();

  const [form, dispatch] = useReducer(formReducer, initialForm);

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="名前"
        aria-label="名前"
        value={form.name}
        onChange={(event) =>
          dispatch({ type: "changed_name", value: event.target.value })
        }
      />
      <Input
        placeholder="メールアドレス"
        aria-label="メールアドレス"
        value={form.email}
        onChange={(event) =>
          dispatch({ type: "changed_email", value: event.target.value })
        }
      />

      <label className="flex items-center gap-2">
        <input
          type="checkbox"
          checked={form.agreed}
          onChange={() => dispatch({ type: "toggled_agreement" })}
        />
        規約に同意する
      </label>

      {/* リセットは 1 行。中身が増えても、ここは変わらない */}
      <Button
        size="sm"
        variant="outline"
        onClick={() => dispatch({ type: "reset" })}
      >
        リセット
      </Button>
    </div>
  );
}

最後に、更新の一覧表を書く

いまの状態起きたことを受け取って、次の状態を返す関数です。これを reducer と呼びます。

function formReducer(state, action) {
  // いまの状態 + 起きたこと → 次の状態
}

この関数を読めば、このフォームで起こりうる変化が全部わかります。 画面のコードを追いかける必要がありません。

with-reducer.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useReducer } from "react";

// 状態のかたち。1 つのオブジェクトにまとめる
type Form = {
  name: string;
  email: string;
  agreed: boolean;
};

// 「何が起きたか」の一覧。更新のしかたではなく、出来事を書く
type Action =
  | { type: "changed_name"; value: string }
  | { type: "changed_email"; value: string }
  | { type: "toggled_agreement" }
  | { type: "reset" };

const initialForm: Form = {
  name: "",
  email: "",
  agreed: false,
};

// 出来事を受け取って、次の状態を返す関数。ここが更新の一覧表になる
function formReducer(state: Form, action: Action): Form {
  switch (action.type) {
    case "changed_name":
      return { ...state, name: action.value };
    case "changed_email":
      return { ...state, email: action.value };
    case "toggled_agreement":
      return { ...state, agreed: !state.agreed };
    case "reset":
      return initialForm;
  }
}

export function WithReducer() {
  useTrackDemoRender();

  const [form, dispatch] = useReducer(formReducer, initialForm);

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="名前"
        aria-label="名前"
        value={form.name}
        onChange={(event) =>
          dispatch({ type: "changed_name", value: event.target.value })
        }
      />
      <Input
        placeholder="メールアドレス"
        aria-label="メールアドレス"
        value={form.email}
        onChange={(event) =>
          dispatch({ type: "changed_email", value: event.target.value })
        }
      />

      <label className="flex items-center gap-2">
        <input
          type="checkbox"
          checked={form.agreed}
          onChange={() => dispatch({ type: "toggled_agreement" })}
        />
        規約に同意する
      </label>

      {/* リセットは 1 行。中身が増えても、ここは変わらない */}
      <Button
        size="sm"
        variant="outline"
        onClick={() => dispatch({ type: "reset" })}
      >
        リセット
      </Button>
    </div>
  );
}

できあがり

const [form, dispatch] = useReducer(formReducer, initialForm);

useState とよく似た形です。受け取るのはいまの状態出来事を送る関数。 変わったのは、更新のしかたが reducer に集まったことだけです。

useReducer でまとめたフォーム直した例

見た目の動きは同じ。中身の置き方が違う

画面の動きはまったく同じです。変わったのは、リセットの実装がこうなったこと。

// useState 版
setName(""); setEmail(""); setAgreed(false);

// useReducer 版
dispatch({ type: "reset" });

項目が 10 個に増えても、この行は変わりません。 増えた分は reducer の reset が面倒を見ます。 書き忘れようがなくなりました。

with-reducer.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useReducer } from "react";

// 状態のかたち。1 つのオブジェクトにまとめる
type Form = {
  name: string;
  email: string;
  agreed: boolean;
};

// 「何が起きたか」の一覧。更新のしかたではなく、出来事を書く
type Action =
  | { type: "changed_name"; value: string }
  | { type: "changed_email"; value: string }
  | { type: "toggled_agreement" }
  | { type: "reset" };

const initialForm: Form = {
  name: "",
  email: "",
  agreed: false,
};

// 出来事を受け取って、次の状態を返す関数。ここが更新の一覧表になる
function formReducer(state: Form, action: Action): Form {
  switch (action.type) {
    case "changed_name":
      return { ...state, name: action.value };
    case "changed_email":
      return { ...state, email: action.value };
    case "toggled_agreement":
      return { ...state, agreed: !state.agreed };
    case "reset":
      return initialForm;
  }
}

export function WithReducer() {
  useTrackDemoRender();

  const [form, dispatch] = useReducer(formReducer, initialForm);

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="名前"
        aria-label="名前"
        value={form.name}
        onChange={(event) =>
          dispatch({ type: "changed_name", value: event.target.value })
        }
      />
      <Input
        placeholder="メールアドレス"
        aria-label="メールアドレス"
        value={form.email}
        onChange={(event) =>
          dispatch({ type: "changed_email", value: event.target.value })
        }
      />

      <label className="flex items-center gap-2">
        <input
          type="checkbox"
          checked={form.agreed}
          onChange={() => dispatch({ type: "toggled_agreement" })}
        />
        規約に同意する
      </label>

      {/* リセットは 1 行。中身が増えても、ここは変わらない */}
      <Button
        size="sm"
        variant="outline"
        onClick={() => dispatch({ type: "reset" })}
      >
        リセット
      </Button>
    </div>
  );
}

どちらを使うか

useReducer のほうが必ず良い、というわけではありません。書く量は確実に増えます。 その代わり、更新が 1 か所に集まります。

状況選ぶもの
値が 1〜2 個で、単純に入れ替えるだけuseState
1 つの操作で複数の値がまとめて変わるuseReducer
更新のコードが画面のあちこちに散っているuseReducer
「なぜこの値になったか」を追いにくいuseReducer

迷ったら useState で始めてかまいません。つらくなってから移せます。 実際、この章でやったのはまさにその作業でした。

with-reducer.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useReducer } from "react";

// 状態のかたち。1 つのオブジェクトにまとめる
type Form = {
  name: string;
  email: string;
  agreed: boolean;
};

// 「何が起きたか」の一覧。更新のしかたではなく、出来事を書く
type Action =
  | { type: "changed_name"; value: string }
  | { type: "changed_email"; value: string }
  | { type: "toggled_agreement" }
  | { type: "reset" };

const initialForm: Form = {
  name: "",
  email: "",
  agreed: false,
};

// 出来事を受け取って、次の状態を返す関数。ここが更新の一覧表になる
function formReducer(state: Form, action: Action): Form {
  switch (action.type) {
    case "changed_name":
      return { ...state, name: action.value };
    case "changed_email":
      return { ...state, email: action.value };
    case "toggled_agreement":
      return { ...state, agreed: !state.agreed };
    case "reset":
      return initialForm;
  }
}

export function WithReducer() {
  useTrackDemoRender();

  const [form, dispatch] = useReducer(formReducer, initialForm);

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="名前"
        aria-label="名前"
        value={form.name}
        onChange={(event) =>
          dispatch({ type: "changed_name", value: event.target.value })
        }
      />
      <Input
        placeholder="メールアドレス"
        aria-label="メールアドレス"
        value={form.email}
        onChange={(event) =>
          dispatch({ type: "changed_email", value: event.target.value })
        }
      />

      <label className="flex items-center gap-2">
        <input
          type="checkbox"
          checked={form.agreed}
          onChange={() => dispatch({ type: "toggled_agreement" })}
        />
        規約に同意する
      </label>

      {/* リセットは 1 行。中身が増えても、ここは変わらない */}
      <Button
        size="sm"
        variant="outline"
        onClick={() => dispatch({ type: "reset" })}
      >
        リセット
      </Button>
    </div>
  );
}

実務では、どう書かれているか

正直に書いておきます。useReducer をそのまま使う場面は、 実務ではあまり多くありません。

状態を複数の画面で共有したくなった時点で、Zustand のような外部のストアを使うことが多いためです。

with-reducer.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useReducer } from "react";

// 状態のかたち。1 つのオブジェクトにまとめる
type Form = {
  name: string;
  email: string;
  agreed: boolean;
};

// 「何が起きたか」の一覧。更新のしかたではなく、出来事を書く
type Action =
  | { type: "changed_name"; value: string }
  | { type: "changed_email"; value: string }
  | { type: "toggled_agreement" }
  | { type: "reset" };

const initialForm: Form = {
  name: "",
  email: "",
  agreed: false,
};

// 出来事を受け取って、次の状態を返す関数。ここが更新の一覧表になる
function formReducer(state: Form, action: Action): Form {
  switch (action.type) {
    case "changed_name":
      return { ...state, name: action.value };
    case "changed_email":
      return { ...state, email: action.value };
    case "toggled_agreement":
      return { ...state, agreed: !state.agreed };
    case "reset":
      return initialForm;
  }
}

export function WithReducer() {
  useTrackDemoRender();

  const [form, dispatch] = useReducer(formReducer, initialForm);

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="名前"
        aria-label="名前"
        value={form.name}
        onChange={(event) =>
          dispatch({ type: "changed_name", value: event.target.value })
        }
      />
      <Input
        placeholder="メールアドレス"
        aria-label="メールアドレス"
        value={form.email}
        onChange={(event) =>
          dispatch({ type: "changed_email", value: event.target.value })
        }
      />

      <label className="flex items-center gap-2">
        <input
          type="checkbox"
          checked={form.agreed}
          onChange={() => dispatch({ type: "toggled_agreement" })}
        />
        規約に同意する
      </label>

      {/* リセットは 1 行。中身が増えても、ここは変わらない */}
      <Button
        size="sm"
        variant="outline"
        onClick={() => dispatch({ type: "reset" })}
      >
        リセット
      </Button>
    </div>
  );
}

理解できたか確かめる

確認クイズ

アクションの名前として適切なのは?

確認クイズ

reducer の中で state.name = value と書いてよい?

確認クイズ

useReducer に移して得られるのは?

確認クイズ

dispatch を子コンポーネントに渡すとき、useCallback で包む必要はある?

with-reducer.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useReducer } from "react";

// 状態のかたち。1 つのオブジェクトにまとめる
type Form = {
  name: string;
  email: string;
  agreed: boolean;
};

// 「何が起きたか」の一覧。更新のしかたではなく、出来事を書く
type Action =
  | { type: "changed_name"; value: string }
  | { type: "changed_email"; value: string }
  | { type: "toggled_agreement" }
  | { type: "reset" };

const initialForm: Form = {
  name: "",
  email: "",
  agreed: false,
};

// 出来事を受け取って、次の状態を返す関数。ここが更新の一覧表になる
function formReducer(state: Form, action: Action): Form {
  switch (action.type) {
    case "changed_name":
      return { ...state, name: action.value };
    case "changed_email":
      return { ...state, email: action.value };
    case "toggled_agreement":
      return { ...state, agreed: !state.agreed };
    case "reset":
      return initialForm;
  }
}

export function WithReducer() {
  useTrackDemoRender();

  const [form, dispatch] = useReducer(formReducer, initialForm);

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="名前"
        aria-label="名前"
        value={form.name}
        onChange={(event) =>
          dispatch({ type: "changed_name", value: event.target.value })
        }
      />
      <Input
        placeholder="メールアドレス"
        aria-label="メールアドレス"
        value={form.email}
        onChange={(event) =>
          dispatch({ type: "changed_email", value: event.target.value })
        }
      />

      <label className="flex items-center gap-2">
        <input
          type="checkbox"
          checked={form.agreed}
          onChange={() => dispatch({ type: "toggled_agreement" })}
        />
        規約に同意する
      </label>

      {/* リセットは 1 行。中身が増えても、ここは変わらない */}
      <Button
        size="sm"
        variant="outline"
        onClick={() => dispatch({ type: "reset" })}
      >
        リセット
      </Button>
    </div>
  );
}

この章のまとめ

  • state が増え、まとめて変える操作が出てきたらuseReducer の出番
  • アクションは「どう変えるか」ではなく「何が起きたか」で書く
  • reducer は純粋な関数。state を直接書き換えない
  • 書く量は増える。代わりに更新が 1 か所に集まる
  • 迷ったら useState から始めて、つらくなったら移す

with-reducer.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useReducer } from "react";

// 状態のかたち。1 つのオブジェクトにまとめる
type Form = {
  name: string;
  email: string;
  agreed: boolean;
};

// 「何が起きたか」の一覧。更新のしかたではなく、出来事を書く
type Action =
  | { type: "changed_name"; value: string }
  | { type: "changed_email"; value: string }
  | { type: "toggled_agreement" }
  | { type: "reset" };

const initialForm: Form = {
  name: "",
  email: "",
  agreed: false,
};

// 出来事を受け取って、次の状態を返す関数。ここが更新の一覧表になる
function formReducer(state: Form, action: Action): Form {
  switch (action.type) {
    case "changed_name":
      return { ...state, name: action.value };
    case "changed_email":
      return { ...state, email: action.value };
    case "toggled_agreement":
      return { ...state, agreed: !state.agreed };
    case "reset":
      return initialForm;
  }
}

export function WithReducer() {
  useTrackDemoRender();

  const [form, dispatch] = useReducer(formReducer, initialForm);

  return (
    <div className="flex flex-col gap-3">
      <Input
        placeholder="名前"
        aria-label="名前"
        value={form.name}
        onChange={(event) =>
          dispatch({ type: "changed_name", value: event.target.value })
        }
      />
      <Input
        placeholder="メールアドレス"
        aria-label="メールアドレス"
        value={form.email}
        onChange={(event) =>
          dispatch({ type: "changed_email", value: event.target.value })
        }
      />

      <label className="flex items-center gap-2">
        <input
          type="checkbox"
          checked={form.agreed}
          onChange={() => dispatch({ type: "toggled_agreement" })}
        />
        規約に同意する
      </label>

      {/* リセットは 1 行。中身が増えても、ここは変わらない */}
      <Button
        size="sm"
        variant="outline"
        onClick={() => dispatch({ type: "reset" })}
      >
        リセット
      </Button>
    </div>
  );
}