🐷

ReactのReducerとContextを使う素朴な状態管理

2022/12/13に公開

勉強メモ

参考資料は公式のドキュメント、特に新規の話はない

https://beta.reactjs.org/learn/passing-data-deeply-with-context

https://beta.reactjs.org/learn/scaling-up-with-reducer-and-context

Reducerとは

そもそもReducerを使うメリットを考えると状態と操作は同時に存在することが前提になることが多い。例えば(loading: Bool, fetchedData: [String])のような状態を考える。useStateだけを使った場合には[ids, setIds]のようなものとこれをよしなに処理する関数を複数用意することになる場合が多い。これをギュッと値を変えるという考えで作るとuseReducer("ギュッと値を変える処理をまとめたもの", "変えていく値の初期値")のようになる。使うときにはdispatch({type: "ギュッと値を変える処理をまとめたときに付けた処理のキー", p1: "何かしらの付随する値、パラメータとか", p2: "これ移行も付随する値とか", ...})のように書けば値を変更するパターンと変更に利用する値という入力の関係がまとまり出力はその処理のキーによって返却する値になる。Reducerはreduceの概念をベースに作っているのでデータ構造は処理として一貫しているとよい。つまりReducerに初期値として(Bool, [String])のようなデータ構造を入れたとしたらどのような処理をしてもreducerの中で返す値はこの構造は変えないようにする。

基本的なReducerのパターンとは大きく変わらない、型安全にするならTSでenumを使うとか何かしら定数を用意すると良いが、JSにenumがないようなので色々頑張るか全部文字列管理をするっぽい。Reactの公式ドキュメントは普通に文字列で管理している、Swiftのenumに慣れてるとちょっと厳しいね。

基本的なReducerの観点は普通にSwiftのThe Composable Architectureを眺めると想像がいろいろできていい感じ、The Elm Architectureとかそこらへんの知見らしいので適当にドキュメントを引っ張ってくるといい感じ。まぁ標準でComposableに作るのはちょっとめんどくさそうなので考え方は少し違う。

またReducerはそれ単体だと旨味が薄いがReducer同士を組み合わせてReducerのように使えると柔軟性が上がる。

Composed Reducerの例


function todoReducer(tasks, action) {
  switch (action.type) {
    case 'added': {
      return [...tasks, {
        id: action.id,
        text: action.text,
        done: false
      }];
    }
    case 'changed': {
      return tasks.map(t => {
        if (t.id === action.task.id) {
          return action.task;
        } else {
          return t;
        }
      });
    }
    case 'deleted': {
      return tasks.filter(t => t.id !== action.id);
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}

const initialTasks = [
  { id: 0, text: 'Philosopher’s Path', done: true },
  { id: 1, text: 'Visit the temple', done: false },
  { id: 2, text: 'Drink matcha', done: false }
];


const [state, dispatch] = useReducer(todoReducer, initialTasks)

Contextとは

Reactに存在するビュー間の値をテレポートさせる仕組み、SwiftUIのEnvironmentObjectみたいなモノ。値を直接渡すのではなくてどこかに用意した状態変数を経由する感じ。Propsの場合はParent -> Childといった素直なデータの流れになるのに対して、Contextの場合はParent -> なんとかContext.Provider -> 任意の数のChild -> useContext(なんとかContext) -> Childのようになる。Childがどれだけ多くても問題ない。またuseContextとContext.Providerは同じ要素内で同時に使うことができるContextはuseContextを読んだところから一番近い親が定義したProviderを見るため同じContextを子から見ると複数Provideされていても問題ない。もしどこまで行っても親がProvideしていなければcreateContextで設定した初期値が読まれるため値が存在しない場合を作らずに済む。

LevelContext = createContext(1)

export default function Section({ children }) {
  const level = useContext(LevelContext);
  return (
    <section className="section">
      <LevelContext.Provider value={level + 1}>
        {children}
      </LevelContext.Provider>
    </section>
  );
}

export default function Heading({ children }) {
  const level = useContext(LevelContext);
  switch (level) {
    case 1:
      return <h1>{children}</h1>;
    case 2:
      return <h2>{children}</h2>;
    default:
      throw Error('Unknown level: ' + level);
  }
}

<App>
  <Section level={1}>
  <Section level={2}>
</App>

嬉しいことは複数のコンポーネントが同じContextでProvideしていたとしても別の子に対してProvideするなどのコンテキストが被らないような値の設定をしている場合にはお互いの値はそれぞれ独立する。

コンテキストは便利だが無造作にデータを共有する変数のように使うと辛い、基本的には親から子へのpropsを使った値の移動で済む場合が多い。また親が持つ値を子へpropsとして渡すときに親子に値の依存関係がその場で表現されるメリットは忘れてはいけない。

便利なユースケース

  • テーマ設定
  • アプリ共通設定(ログイン中のユーザ情報とか)
  • ルーティング(いまどこ見てる、次どこを見るなどの情報の受け渡し)
  • 状態の管理(ReducerとContextを組み合わせていい感じにセルフで状態管理する)

Context使い方最短

1. export const CustomContext = createContext(defaultValue)
2. <CustomContext.Provider value={nextValue}>
3. const value = useContext(CustomContext)

ReducerとContextを合わせて使う

reducerはそれ単体で値の更新をまとめて扱う、そのためonXXXのようなイベントハンドラを複数持つような処理と捉えることができる。useReducerで値と処理を(state, dispatch)の関係として受け取れるため2つのコンテキストを用意してそれぞれをProviderに入れると値の観測のみ、値の変更操作のみ、観測と変更の両方をするなどの柔軟な状態管理に使うことができる。useXXXはカスタムフックとして特別扱いされてHooksの内部(useStateと同じような感じ)で扱えるようになる。なのでuseContext(TasksContext)のように定義すると利用側で何をContextに持つのか教えなくても良くなる。

TasksContext.js
import { createContext, useContext, useReducer } from 'react';

const TasksContext = createContext(null);

const TasksDispatchContext = createContext(null);

export function TasksProvider({ children }) {
  const [tasks, dispatch] = useReducer(
    tasksReducer,
    initialTasks
  );

  return (
    <TasksContext.Provider value={tasks}>
      <TasksDispatchContext.Provider value={dispatch}>
        {children}
      </TasksDispatchContext.Provider>
    </TasksContext.Provider>
  );
}

export function useTasks() {
  return useContext(TasksContext);
}

export function useTasksDispatch() {
  return useContext(TasksDispatchContext);
}

function tasksReducer(tasks, action) {
  switch (action.type) {
    case 'added': {
      return [...tasks, {
        id: action.id,
        text: action.text,
        done: false
      }];
    }
    case 'changed': {
      return tasks.map(t => {
        if (t.id === action.task.id) {
          return action.task;
        } else {
          return t;
        }
      });
    }
    case 'deleted': {
      return tasks.filter(t => t.id !== action.id);
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}

const initialTasks = [
  { id: 0, text: 'Philosopher’s Path', done: true },
  { id: 1, text: 'Visit the temple', done: false },
  { id: 2, text: 'Drink matcha', done: false }
];
app.js
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksProvider } from './TasksContext.js';


export default function TaskApp() {
  return (
    <TasksProvider>
      <h1>Day off in Kyoto</h1>
      <AddTask />
      <TaskList />
    </TasksProvider>
  );
}

Discussion