Skip to the content.

状態管理 その他の状態管理 useReducer, useContext

目次

useReducerの使い方

useReducerもuseStateと同じように、状態管理を行うものです。状態更新の方法が異なります。簡単にuseReducerの使い方を見ていきましょう。

import { useReducer } from 'react';

const reducer = (state, action) => {
  // ...
};

const MyComponent = () => {
  const [state, dispatch] = useReducer(reducer, { age: 42 });
  // ...
};

useReducerの引数と返値は、以下のとおりです。

useReducerの引数

useReducerの返値

useReducerは、reducerを指定しているということと、state更新用のdispatchがあるという点で、useStateと異なります。

まずは、reducerをどのように指定するかを見ていきましょう。

reducerは、2つの引数を受け取ります。

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を利用する場合、stateactionの型の定義が必要です。この例の場合、以下のように定義します。

type Action =
  | {
      type: "increment_age";
    }
  | { type: "change_age"; payload: number };

interface State {
  age: number;
}

const reducer = (state: State, action: Action) => {
  // ...
};

useReducerの利点

useReduceruseStateと比べて良いところは、以下のとおりです。どれも保守性に関わるもので、大規模な開発や、さまざまなコンポーネントで使用される状態の管理に使うと良さそうです。

  1. 更新の方法を、限定できるuseReducerは、reducerで定義された方法でしか状態の更新ができません。そのため、勝手に書き換えられたくないプロパティがある場合や、他のプロパティから自動的に計算されるプロパティなどがある場合に、勝手な書き換えや更新のし忘れを防ぐことができます。

  2. 更新用の関数は、dispatchのみ: 1の利点は、useStateでも更新用の関数を1つ1つ定義すれば、実現できます。ただ、子コンポーネントに大量の更新用の関数を渡すとpropsが煩雑になってしまい、可読性が落ちてしまいます。useReducerであれば、更新用の関数は、dispatchのみなので、子コンポーネントにもdispatchを渡すだけで十分です。

  3. 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.tsxMyFormReducerを追加しましょう。

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は、stringYYYY-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について説明します。ただし、グローバルな状態管理には、reduxzustandなどの有名なライブラリがあり、こちらを使う方が、パフォーマンス面で良いとされています。そのため、実務ではライブラリを使った方が良いでしょう。

知識として、reactにもグローバルな状態管理ができるuseContextがあるということを覚えておいてください。

グローバルな状態管理(useContext)の目的

Reactでは、コンポーネント間の状態の共有は、propsを利用します。子コンポーネント同士で状態を共有したい場合、その状態は、親コンポーネントが持つ必要があります。もっと深い子コンポーネント同士で、状態を共有したい場合も同様で、かなり上位のコンポーネントが状態を持つ必要があります。その時中間のコンポーネントは、状態をただ受け渡すだけのpropsが存在することになり、必然的に中間コンポーネントのpropsが煩雑になります。

コンポーネントをツリーとして考えてみましょう。(参考:UI をツリーとして理解する) 以下は、AppコンポーネントからA1, A2コンポーネントを呼び出し、A1, A2はそれぞれB1, B2を呼び出し、B1, B2は、C1, C2を呼び出している図です。

コンポーネントツリー

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

propsリレー

この時、C1, C2以外のコンポーネントは、ただvalueを下に受け渡すだけになります。これが複数の値になってくると中間のコンポーネントは、下に渡すだけのpropsが多くなり、煩雑になっていきます。

これを防ぐために、グローバルな状態管理を行い、不要なpropsの受け渡しを解消していきます。

useContextの使い方

それでは、使い方を見ていきます。

Appコンポーネントでthemeという値を持ち、MyFormReducerコンポーネント内のLabeledInputthemeの値によって、スタイルを変えるようにしましょう。

まず、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に保持しているthemesetThemeuseContextを使って、各コンポーネントに共有していきます。 まず、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>
  );
}

これで、themesetThemeを共有する準備が整いました。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コンポーネントは、値を共有したい範囲の最上位のコンポーネントで使うようにしてください。

それでは、LabeledInputthemeによってスタイルが変わるようにしましょう。それぞれのコンポーネントを以下のように書き換えてください。

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のスタイルを変更することができれば、成功です。

darkテーマのform

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)としても良いですが、useContextThemeContextの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;

themesetThemeを参照しているLabeledInputThemeSwitchは、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を実行し、結果をブラウザで確認してみてください。

Next: Chapter6 useEffect

Prev: Chapter4 状態管理 useState