状態管理 その他の状態管理 useReducer, useContext
目次
- useReducerの使い方
- useReducer演習
- グローバルな状態管理useContext
- Next: Chapter6 useEffect
- Prev: Chapter4 状態管理 useState
useReducerの使い方
useReducerもuseStateと同じように、状態管理を行うものです。状態更新の方法が異なります。簡単にuseReducerの使い方を見ていきましょう。
import { useReducer } from 'react';
const reducer = (state, action) => {
// ...
};
const MyComponent = () => {
const [state, dispatch] = useReducer(reducer, { age: 42 });
// ...
};
useReducerの引数と返値は、以下のとおりです。
useReducerの引数
reducer:更新用の関数initialState:状態の初期値(上記の例だと{ age: 42 })
useReducerの返値
state:現在の状態dispatch:state更新用の関数
useReducerは、reducerを指定しているということと、state更新用のdispatchがあるという点で、useStateと異なります。
まずは、reducerをどのように指定するかを見ていきましょう。
reducerは、2つの引数を受け取ります。
state:現在の状態action:dispatchに渡されたオブジェクト
actionには、Reactの慣習的に以下のようなプロパティを持ちます。
interface Action {
type: string;
payload: any;
}
reducerでは、action.typeに応じて、状態の変更を行います。また、状態の変更に必要な値やオブジェクトがある場合、payloadにそれを指定します。
const reducer = (state, action) => {
switch (action.type) {
case "increment_age":
return { ...state, age: state.age + 1 };
case "change_age":
return { ...state, age: action.payload };
default:
throw Error("Unknown action.");
}
};
上記のようなreducerを指定した場合、dispatchは以下のように使用します。
const MyComponent = () => {
const [state, dispatch] = useReducer(reducer, { age: 42 });
// ageを+1する場合
dispatch({ type: "increment_age" });
// ageを新しい値に変更する場合
dispatch({ type: "change_age", payload: 30 });
// ...
};
この資料では、typescriptも使用するので、型も定義しましょう。useReducerを利用する場合、stateとactionの型の定義が必要です。この例の場合、以下のように定義します。
type Action =
| {
type: "increment_age";
}
| { type: "change_age"; payload: number };
interface State {
age: number;
}
const reducer = (state: State, action: Action) => {
// ...
};
useReducerの利点
useReducerがuseStateと比べて良いところは、以下のとおりです。どれも保守性に関わるもので、大規模な開発や、さまざまなコンポーネントで使用される状態の管理に使うと良さそうです。
-
更新の方法を、限定できる:
useReducerは、reducerで定義された方法でしか状態の更新ができません。そのため、勝手に書き換えられたくないプロパティがある場合や、他のプロパティから自動的に計算されるプロパティなどがある場合に、勝手な書き換えや更新のし忘れを防ぐことができます。 -
更新用の関数は、
dispatchのみ: 1の利点は、useStateでも更新用の関数を1つ1つ定義すれば、実現できます。ただ、子コンポーネントに大量の更新用の関数を渡すとpropsが煩雑になってしまい、可読性が落ちてしまいます。useReducerであれば、更新用の関数は、dispatchのみなので、子コンポーネントにもdispatchを渡すだけで十分です。 -
reducerは、純粋関数:reducerは、stateに依存せず純粋な関数として定義ができます。そのため、reducerの単体テストを書くことができます。
useReducer演習
それでは、前の章で作成したフォームを今回は、useReducerを使って実装しましょう。
コンポーネントの準備
まずは、基本となるコンポーネントを準備します。この内容は、前の章のuseStateの演習と同じです。
MyFormReducerコンポーネントをつくります。
src/components/MyFormReducer.tsx
import { useReducer } from "react";
import LabeledInput from "./LabeledInput";
import LabeledSelectInput from "./LabeledSelectInput";
type Action = { type: string };
interface State {
text: string;
number: number;
date: Date;
check: string[];
radio: string;
}
const reducer = (state: State, action: Action) => {
// ...
return state;
};
const MyFormReducer = () => {
const [state, dispatch] = useReducer(reducer, {
text: "",
number: 0,
date: new Date(),
check: [],
radio: "",
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
// submitはデフォルトで再読み込みするようになっているので、
// それを行わないようにする。
e.preventDefault();
console.log(state);
};
return (
<form className="mx-auto w-1/2 space-y-2" onSubmit={handleSubmit}>
<LabeledInput label="Text" type="text" />
<LabeledInput label="Number" type="number" />
<LabeledInput label="Date" type="date" />
<div className="grid grid-cols-4 gap-4">
{["A", "B", "C"].map((value) => (
<LabeledSelectInput
key={value}
type="checkbox"
label={value}
value={value}
/>
))}
</div>
<div className="grid grid-cols-4 gap-4">
{["a", "b", "c"].map((value) => (
<LabeledSelectInput
key={value}
type="radio"
label={value}
value={value}
/>
))}
</div>
<button
type="submit"
className="mb-2 me-2 rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Submit
</button>
</form>
);
};
export default MyFormReducer;
App.tsxにMyFormReducerを追加しましょう。
src/App.tsx
import MyFormReducer from "./components/MyFormReducer";
function App() {
return (
<div className="m-4 space-y-2">
<MyFormReducer />
</div>
);
}
export default App;
注意点:今回は、state.dateの型はDateオブジェクトに、state.numberの型はnumberになっています。input要素のtype="date"となっているところのvalueは、stringでYYYY-MM-DDの形式にしてください。
解答例
以下に解答例を示します。他の実装方法もたくさんあります。
import { useReducer } from "react";
import LabeledInput from "./LabeledInput";
import LabeledSelectInput from "./LabeledSelectInput";
type Action =
| { type: "changeText"; payload: string }
| { type: "changeNumber"; payload: string }
| { type: "changeDate"; payload: string }
| { type: "changeCheck"; payload: string }
| { type: "changeRadio"; payload: string };
interface State {
text: string;
number: number;
date: Date;
check: string[];
radio: string;
}
const reducer = (state: State, { type, payload }: Action): State => {
switch (type) {
case "changeText":
return { ...state, text: payload };
case "changeNumber":
return { ...state, number: parseInt(payload) };
case "changeDate":
return { ...state, date: new Date(payload) };
case "changeCheck":
if (state.check.includes(payload)) {
return { ...state, check: state.check.filter((v) => v !== payload) };
} else {
return { ...state, check: [...state.check, payload] };
}
case "changeRadio":
return { ...state, radio: payload };
default:
throw Error("Unknown action type");
}
};
const MyFormReducer = () => {
const [state, dispatch] = useReducer(reducer, {
text: "",
number: 0,
date: new Date(),
check: [],
radio: "",
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
// submitはデフォルトで再読み込みするようになっているので、
// それを行わないようにする。
e.preventDefault();
console.log(state);
};
return (
<form className="mx-auto w-1/2 space-y-2" onSubmit={handleSubmit}>
<LabeledInput
label="Text"
type="text"
value={state.text}
onChange={(e) => {
dispatch({ type: "changeText", payload: e.target.value });
}}
/>
<LabeledInput
label="Number"
type="number"
value={state.number}
onChange={(e) => {
dispatch({ type: "changeNumber", payload: e.target.value });
}}
/>
<LabeledInput
label="Date"
type="date"
value={state.date.toISOString().split("T")[0]}
onChange={(e) => {
dispatch({ type: "changeDate", payload: e.target.value });
}}
/>
<div className="grid grid-cols-4 gap-4">
{["A", "B", "C"].map((value) => (
<LabeledSelectInput
key={value}
type="checkbox"
label={value}
value={value}
checked={state.check.includes(value)}
onChange={(e) => {
dispatch({ type: "changeCheck", payload: e.target.value });
}}
/>
))}
</div>
<div className="grid grid-cols-4 gap-4">
{["a", "b", "c"].map((value) => (
<LabeledSelectInput
key={value}
type="radio"
label={value}
value={value}
checked={state.radio === value}
onChange={(e) => {
dispatch({ type: "changeRadio", payload: e.target.value });
}}
/>
))}
</div>
<button
type="submit"
className="mb-2 me-2 rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Submit
</button>
</form>
);
};
export default MyFormReducer;
グローバルな状態管理useContext
ここでは、グローバルな状態管理の方法useContextについて説明します。ただし、グローバルな状態管理には、reduxやzustandなどの有名なライブラリがあり、こちらを使う方が、パフォーマンス面で良いとされています。そのため、実務ではライブラリを使った方が良いでしょう。
知識として、reactにもグローバルな状態管理ができるuseContextがあるということを覚えておいてください。
グローバルな状態管理(useContext)の目的
Reactでは、コンポーネント間の状態の共有は、propsを利用します。子コンポーネント同士で状態を共有したい場合、その状態は、親コンポーネントが持つ必要があります。もっと深い子コンポーネント同士で、状態を共有したい場合も同様で、かなり上位のコンポーネントが状態を持つ必要があります。その時中間のコンポーネントは、状態をただ受け渡すだけのpropsが存在することになり、必然的に中間コンポーネントのpropsが煩雑になります。
コンポーネントをツリーとして考えてみましょう。(参考:UI をツリーとして理解する)
以下は、AppコンポーネントからA1, A2コンポーネントを呼び出し、A1, A2はそれぞれB1, B2を呼び出し、B1, B2は、C1, C2を呼び出している図です。

ここで、C1, C2コンポーネントが、値を共有したい場合、Appコンポーネントがその共有する値を持ち、下に渡していく必要があります。

この時、C1, C2以外のコンポーネントは、ただvalueを下に受け渡すだけになります。これが複数の値になってくると中間のコンポーネントは、下に渡すだけのpropsが多くなり、煩雑になっていきます。
これを防ぐために、グローバルな状態管理を行い、不要なpropsの受け渡しを解消していきます。
useContextの使い方
それでは、使い方を見ていきます。
Appコンポーネントでthemeという値を持ち、MyFormReducerコンポーネント内のLabeledInputがthemeの値によって、スタイルを変えるようにしましょう。
まず、Appコンポーネント内にthemeという値を保持しましょう。
src/App.tsx
import { useState } from "react";
import MyFormReducer from "./components/MyFormReducer";
function App() {
const [theme, setTheme] = useState("light");
return (
<div className="m-4 space-y-2">
<MyFormReducer />
</div>
);
}
export default App;
Appに保持しているthemeとsetThemeをuseContextを使って、各コンポーネントに共有していきます。
まず、App.tsxに以下を追加します。(Appコンポーネントの外に記述してください。)
import { createContext, useState } from "react";
import MyFormReducer from "./components/MyFormReducer";
export const ThemeContext = createContext<{
theme: string;
setTheme: React.Dispatch<React.SetStateAction<string>>;
}>({ theme: "", setTheme: () => {} });
function App() {
// ...
}
createContextの引数には、共有する状態の初期値を渡してください。このThemeContextを使ってAppコンポーネントを以下のように書き換えます。
Appコンポーネントの返値を、ThemeContext.Providerコンポーネントで囲い、グローバルに共有したい値をThemeContext.Providerコンポーネントのvalueに指定します。
function App() {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<div className="m-4 space-y-2">
<MyFormReducer />
</div>
</ThemeContext.Provider>
);
}
これで、themeとsetThemeを共有する準備が整いました。ThemeContext.Providerコンポーネントで囲ったことで、valuepropsの値をuseContextを使って参照することができます。
それでは、グローバルに共有されている値を、LabeledInputコンポーネントで読み取って見ましょう。LabeledInputを以下のように書き換えましょう。
src/components/LabeledInput.tsx
import { useContext } from "react";
import { ThemeContext } from "../App";
interface LabeledInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
}
const LabeledInput: React.FC<LabeledInputProps> = ({ label, ...props }) => {
const { theme } = useContext(ThemeContext);
console.log(theme);
return (
<div>
<label className="mb-2 text-sm font-medium text-gray-900">{label}</label>
<input
className="w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
{...props}
/>
</div>
);
};
export default LabeledInput;
LabeledInput内で、themeの値を参照し、コンソールに表示させました。npm run devを実行し、ブラウザでコンソールを確認してみましょう。MyFormReducerコンポーネント内で、LabeledInputコンポーネントは、3つ使われており、React.StrictModeが有効なので、3×2回lightとコンソールに表示されるはずです。
これでpropsで渡さなくても、useContextを使って値を参照できました。この方法で、グローバルな状態管理ができます。1つ注意点として、ThemeContext.Providerコンポーネントで囲った範囲でしか値を共有することができません。そのため、Providerコンポーネントは、値を共有したい範囲の最上位のコンポーネントで使うようにしてください。
それでは、LabeledInputがthemeによってスタイルが変わるようにしましょう。それぞれのコンポーネントを以下のように書き換えてください。
src/components/LabeledInput.tsx
import { useContext } from "react";
import { ThemeContext } from "../App";
interface LabeledInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
}
const LabeledInput: React.FC<LabeledInputProps> = ({ label, ...props }) => {
const { theme } = useContext(ThemeContext);
const className =
theme === "dark"
? "w-full rounded-lg border border-gray-300 bg-gray-800 p-2.5 text-sm text-gray-50 focus:border-blue-600 focus:ring-blue-600"
: "w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500";
return (
<div>
<label className="mb-2 text-sm font-medium text-gray-900">{label}</label>
<input className={className} {...props} />
</div>
);
};
export default LabeledInput;
さらにthemeを切り替えるボタンを用意しましょう。新たにThemeSwitchコンポーネントを用意し、useContextを使ってsetThemeを参照し、tehemeの値を制御しましょう。以下のファイル作成してください。
src/components/ThemeSwitch.tsx
import { useContext } from "react";
import LabeledSelectInput from "./LabeledSelectInput";
import { ThemeContext } from "../App";
const ThemeSwitch: React.FC = () => {
const { theme, setTheme } = useContext(ThemeContext);
return (
<div className="mx-auto grid w-1/2 grid-cols-4 gap-4">
{["light", "dark"].map((value) => (
<LabeledSelectInput
key={value}
type="radio"
label={value}
value={value}
checked={theme === value}
onChange={(e) => {
setTheme(e.target.value);
}}
/>
))}
</div>
);
};
export default ThemeSwitch;
このコンポーネントを利用することで、themeの値を変更できます。このコンポーネントをAppに追加しましょう。App.tsxを以下のように変更します。
srx/App.tsx
import { createContext, useState } from "react";
import MyFormReducer from "./components/MyFormReducer";
import ThemeSwitch from "./components/ThemeSwitch";
export const ThemeContext = createContext<{
theme: string;
setTheme: React.Dispatch<React.SetStateAction<string>>;
}>({ theme: "", setTheme: () => {} });
function App() {
const [theme, setTheme] = useState("dark");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<div className="m-4 space-y-2">
<ThemeSwitch />
<MyFormReducer />
</div>
</ThemeContext.Provider>
);
}
export default App;
npm run devを実行し、結果をブラウザで確認しましょう。上のラジオボタンでthemeを変更し、LabeledInputのスタイルを変更することができれば、成功です。

useContextのファイル分け
useContextを使ったグローバルな状態管理では、状態を受け渡すProviderコンポーネントは、別ファイルで管理されることが多いです。目的としては、グローバルな状態を他のコンポーネントから分離するためと、グローバルな状態を扱うコンポーネントでの記述がより簡潔になるようにするためです。
ここでは、ファイル分けの一例を紹介します。
それでは、Appコンポーネントで使ったグローバルな状態管理に関わるものを別ファイルに移していきます。
useContextをつかった状態管理では、src/contextディレクトリ配下で行います。以下のファイルを作成してください。
src/context/ThemeContext.tsx
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext<{
theme: string;
setTheme: React.Dispatch<React.SetStateAction<string>>;
}>({ theme: "", setTheme: () => {} });
interface ThemeProviderProps {
children: React.ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => useContext(ThemeContext);
ThemeContextは、App.tsxから移動させただけです。
ThemeProviderコンポーネントは、useStateで状態を保持し、ThemeContext.Providerコンポーネントをつかってchildrenを囲みます。これによって、状態管理は、このコンポーネントに移り、themeに関する状態管理をAppから分離できます。
また、状態を参照する場合、useContext(ThemeContext)としても良いですが、useContextとThemeContextの2つインポートする必要があるので、1つで済むように、useThemeを用意しました。
それでは、これらを使って他のコンポーネントを書き換えていきます。
まず、Appコンポーネントは、ThemeProviderを使うと以下のようになります。
import MyFormReducer from "./components/MyFormReducer";
import ThemeSwitch from "./components/ThemeSwitch";
import { ThemeProvider } from "./context/ThemeContext";
function App() {
return (
<ThemeProvider>
<div className="m-4 space-y-2">
<ThemeSwitch />
<MyFormReducer />
</div>
</ThemeProvider>
);
}
export default App;
themeやsetThemeを参照しているLabeledInput、ThemeSwitchは、useThemeを使って以下のようになります。
src/components/LabeledInput.tsx
import { useTheme } from "../context/ThemeContext";
interface LabeledInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
}
const LabeledInput: React.FC<LabeledInputProps> = ({ label, ...props }) => {
const { theme } = useTheme();
const className =
theme === "dark"
? "w-full rounded-lg border border-gray-300 bg-gray-800 p-2.5 text-sm text-gray-50 focus:border-blue-600 focus:ring-blue-600"
: "w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500";
return (
<div>
<label className="mb-2 text-sm font-medium text-gray-900">{label}</label>
<input className={className} {...props} />
</div>
);
};
export default LabeledInput;
src/components/ThemeSwitch.tsx
import { useTheme } from "../context/ThemeContext";
import LabeledSelectInput from "./LabeledSelectInput";
const ThemeSwitch: React.FC = () => {
const { theme, setTheme } = useTheme();
return (
<div className="mx-auto grid w-1/2 grid-cols-4 gap-4">
{["light", "dark"].map((value) => (
<LabeledSelectInput
key={value}
type="radio"
label={value}
value={value}
checked={theme === value}
onChange={(e) => {
setTheme(e.target.value);
}}
/>
))}
</div>
);
};
export default ThemeSwitch;
これで、これまでと全く同じ制御ができるはずです。npm run devを実行し、結果をブラウザで確認してみてください。