Part 4 · 状態 (state)
31 / 64 章
useReducer
更新のしかたが増えてきたら、1 か所にまとめる
useState は、状態が 1 つか 2 つのうちは快適です。
ですが数が増え、「リセット」「下書きを読み込む」のようにまとめて変える操作が出てくると、 更新のコードが画面のあちこちに散らばりはじめます。
useReducer は、その散らばりを1 か所に集めるための道具です。
散らばっていく様子を見る
名前・メール・同意チェックの 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 に集まったことだけです。
見た目の動きは同じ。中身の置き方が違う
画面の動きはまったく同じです。変わったのは、リセットの実装がこうなったこと。
// 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>
);
}