Redux Toolkit の EntityAdapter をさわってみる

Redux Toolkit の AsyncThunk をさわってみる のつづき。

EntityAdapter とは

エンティティとは

  • アプリケーション内の一意のタイプのデータオブジェクトを指すために使用される用語
  • 以下のよう一意の ID 値を持っているオブジェクトを格納したコレクションに対し、CRUD 操作を行う
{
  tasks: [
    { id: 1, title: "aaa", done: false },
    { id: 2, title: "bbb", done: false },
  ];
}
  • プリミティブなオブジェクトだけ扱える(クラスインスタンスは扱えない)

アダブターと初期 state の生成

  • Redux Toolkit のcreateEntityAdapterでアダプターを生成し、アダプターのgetInitialState()で初期 state を得る
interface Task {
  id: number;
  title: string;
  done: boolean;
}
const tasksAdapter = createEntityAdapter<Task>();

const taskInitialEntityState = tasksAdapter.getInitialState();
  • getInitialState()で、次のような初期 state が返される
{
  ids: [];
  entities: {
  }
}
  • 例えば、アダプターを使ってデータ2件追加すると、state は次のような状態になる
{
  ids: [1, 2]
  entities: {
    {
      1: {id: 1, title: 'aaa', done: false},
      2: {id: 2, title: 'bbb', done: false}
    }
  }
}
  • idsentities以外のデータも扱いたい場合はどうする?
    • EntityState<Task>型を拡張して、追加したプロパティの初期値を設定する
interface TaskEntityState extends EntityState<Task> {
  lastId: number;
}

// 初期state生成時に、追加したプロパティの値を指定する
const taskInitialEntityState: TaskEntityState = tasksAdapter.getInitialState({
  lastId: 2,
});
  • idではなくtaskIdという名前で、識別子を管理したい場合は?
    • アダプター生成時に次のように、idを取得する関数を指定する
const tasksAdapter = createEntityAdapter<Task>({
  selectId: (task) => task.taskId,
});
  • idsentitiesの中身を、一定の条件で常にソートしたい場合は?
    • アダプター生成時に次のように、sortComparer()関数を指定する
const tasksAdapter = createEntityAdapter<Task>({
  sortComparer: (a, b) => a.title.localeCompare(b.title),
});

state の追加・更新・削除

  • getInitialState()で生成したEntityStateinitialStateに割り当てる
const todoSlice = createSlice({
  name: 'todo',
  initialState: taskInitialEntityState,
  reducers: { ... }
  ...
  • 例えばEntityState.entitiesをまるまるセットしたい場合は、アダプターのsetAll()メソッドをreducersで使う
const todoSlice = createSlice({
  ...
  reducers: {
    setTasks(state, action: PayloadAction<{ tasks: Task[] }>) {
      tasksAdapter.setAll(state, action.payload.tasks);
    },
  }
})l
  • 複数のEntityStateを扱いたい場合はどうする?
    • 必ずしもEntityStateinitialStateになっている必要はないので、initialStateに複数のEntityStateを持たせれば良い
const todoSlice = createSlice({
  name: 'todo',
  initialState: {
    taskEntityState: taskInitialEntityState,
    barEntityState: barInitialEntityState,
    hoge: {...},
    fuga: {...},
  },
  reducers: {
    setTasks(state, action: PayloadAction<{ tasks: Task[] }>) {
      tasksAdapter.setAll(state, action.payload.tasks);
    },
    setBars(state, action: PayloadAction<{ bars: Bar[] }>) {
      barsAdapter.setAll(state, action.payload.bars);
    },
  }
});
  • setAll 以外にも以下メソッドがある
    • addOne、addMany
    • removeOne、removeMany
    • updateOne、updateMany
    • upsertOne、upsertMany

EntityState の参照

  • 普通に以下のようにコンポーネント側から参照できるけど、この場合メモ化はされない
const { entities } = useSelector(
  (state: RootState) => state.todo.taskEntityState
);
  • Redux + React におけるメモ化の必要性とは?
    • Redux の state は「アプリケーション全体に対して 1 つのツリーオブジェクト(Single source of truth)」 という原則がある
    • このツリーが大きくなった時、各コンポーネントが関心を持つのは、ツリーの中にある一部の state に限定される事が多い
    • しかし、無関係なツリーの一部が更新されても、上記useSelectorの処理は毎度走ってしまう
      • この時、useSelectorで配列走査とかしてると無駄な負荷がかかってしまう
      • なので参照している一部の state に変化がない限りは、cache を返すというメモ化処置が有効になる
  • Redux には「Reselect」というメモ化ライブラリがあり、EntityAdapter が提供するセレクター関数は、このライブラリのcreateSelector関数で作られているためメモ化された参照になる
  • 最初に、参照する state ツリーを限定して、セレクター関数郡を取得する
export const taskSelectors = tasksAdapter.getSelectors(
  (state: RootState) => state.todo.taskEntityState
);
  • Entity を全件取得するにはselectors.selectAll()を使う
// コンポーネント側
const tasks = useSelector(taskSelectors.selectAll);

return (
  <div>
    {tasks.map((task) => (
      ...
    ))}
  </div>
);
  • useSelector は RootState を selector 関数に渡し、selectors.selectAll()RootStateのみを受け取るので、上記処理は以下と同じ
const tasks = useSelector((state: RootState) => {
  return taskSelectors.selectAll(state);
});
  • selectors.selectById()は、RootStateidを引数にとるので次のように書く
const task = useSelector((state: RootState) => {
  return taskSelectors.selectById(state, 1);
});
  • 以下のようなセレクターがある
    • selectIds
    • selectEntities
    • selectAll
    • selectTotal
    • selectById

メモ化して独自のセレクタを書く

  • 以下createSelectorを使って、独自セレクターをメモ化する
function createSelector<S, R1, T>(
  selector: Selector<S, R1>,
  combiner: (res: R1) => T,
): OutputSelector<S, T, (res: R1) => T>;
  • 上記selectorは任意の数のだけ指定でき、combinerselectorの返却値を引数として取るので、以下のように書ける
const barSelector = createSelector(
  (_state: RootState, bar: number) => bar,
  (_state: RootState) => 2,
  (_state: RootState) => 3,
  (a, b, c) => a + b + c
);
const bar = barSelector(store.getState(), 10);
console.log(bar); // 15
  • 最後に追加されたタスクの ID は、taskEntityState.lastIdで管理されていると仮定した場合、このタスクのデータを取得するには以下のように書ける
export const lastTaskSelector = createSelector(
  (state: RootState) => state,
  (state: RootState) => state.todo.taskEntityState.lastId,
  taskSelectors.selectById
);

// コンポーネント側
const lastTask = useSelector(lastTaskSelector);
  • コンポーネント側から state 以外の引数を指定するセレクターを書くには、以下のようにpaylaodでまとめて書くこともできる(まとめず個別に渡す場合は、createSelector で指定する selector 関数を引数の数の分だけ書くことになる)
export const tasksSearchSelectorByTitle = createSelector(
  (payload: { state: RootState; title: string }) => payload,
  (payload) => {
    return taskSelectors.selectAll(payload.state).find((item) => {
      return item.title.match(payload.title);
    });
  }
);

// コンポーネント側
const tasks = useSelector((state: RootState) => {
  return tasksSearchSelectorByTitle({ state, title: "hoge" });
});

もう 1 つの selector の書き方

  • さっきは以下のように、ツリーの参照箇所を絞ってセレクター関数郡を取得した
export const taskSelectors = tasksAdapter.getSelectors(
  (state: RootState) => state.todo.taskEntityState
);
  • 以下のように扱う state の対象を絞らないで書くこともできる
const taskSelectors = tasksAdapter.getSelectors();
  • 但し、この場合は利用するアダプターに対応する state を都度、渡す必要がある
// コンポーネント側
const tasks = useSelector((state: RootState) => {
  return taskSelectors.selectAll(state.todo.taskEntityState); // アダプターに対応するstate
});

// セレクターの生成
export const allTaskSelector = createSelector(
  (state: RootState) => state.todo.taskEntityState, // アダプターに対応するstate
  taskSelectors.selectAll
);

試したコード