💊

RTK Queryでデータを取得し、Custom Hooksで操作をカプセル化する

2024/12/06に公開

この記事はUMITRON Advent Calendar 2024 6日目の記事です。

まえがき

弊社のプロダクト(以下、弊プロダクト)ではRedux ToolkitのcreateSlicecreateAsyncThunkを使ってAPIから取得したデータの管理を行なっています。しかし、Local Stateの初期化に使われているのみの箇所が多く見られ、無理にRedux Toolkitを使う意味があるのか疑問です。また、Local Stateは素のuseStateで管理されているものが多く、認知負荷の高いコードを生む原因となっています。
今回はRTK QueryでAPIからデータを取得し、Custom Hooksでデータへの操作をカプセル化してみました。

何が問題になっているか

揺れるSliceの立ち位置

Redux ToolkitのcreateSliceはstateとreducerからaction creatorとaction typeを生成してSliceとして返す関数です。本来はactionとreducerがまとめられる便利ツールなのですが、弊プロダクトではcreateAsyncThunkと一緒に使うことが前提となっていて、以下の例のように非同期で取得したデータを置く場所(置く場所はstateか)のようになっています。

import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';

const itemsAdapter = createEntityAdapter<Item>();

const items = createSlice({
  name: 'items',
  initialState: itemsAdapter.getInitialState(),
  reducers: {}, // reducerがない!!!
  extraReducers: (builder) => {
    builder
      .addCase(fetchItems.fulfilled, itemsAdapter.setAll)
      .addCase(createItems.fulfilled, itemsAdapter.addOne)
      .addCase(deleteItems.fulfilled, itemsAdapter.removeOne)
      .addCase(updateItems.fulfilled, itemsAdapter.upsertOne);
  },
});

export const { selectAll } = itemsAdapter.getSelectors(
  (state: State) => state.items
);

export default items.reducer;

せっかくSliceを作っても以下のようにLocal Stateの初期化に使われているだけというケースが多く、記述量が増えているだけのように思えます。Thunk Actionは複数のSliceで受けることも可能なので、取得したデータをそのまま表示する画面と取得したデータを書き換える画面があり、後者の状態を前者に反映したくないといった場合、それぞれに対してSliceを与えて対応することができます。Redux的にはおそらくそれが正攻法なのかもしれない(そうでもないかもしれない)ですが、画面から離れたら破棄されるデータをGlobal Stateに持たせたくありません。

const ItemsEditView = () => {
  const dispatch = useDispatch();
  const [items, setItems] = useState<Item[]>([]);
  const initialItems = useSelector(selectAllItems);

  // `fetch`でよくない?
  useEffect(() => {
    dispatch(fetchItems());
  }, []);

  useEffect(() => {
    setItems(initialItems);
  }, [initialItems]);

  return ...;
};

Local Stateに対するプリミティブな操作

直接useStateを使ってしまうと配列に対するプリミティブな操作がそのままコンポーネントに記述されてしまいがちです。見るからにコンポーネントの責務ではありませんし、他のコンポーネントでも同じような操作をするでしょうから、どうにかして分けたいです。

const ItemsEditView = () => {
  const dispatch = useDispatch();
  const [items, setItems] = useState<Item[]>([]);
  const initialItems = useSelector(selectAllItems);

  ...

  // 実際にはこんなものでは済まない
  const updateItem = (id: number, name: string) => {
    setItems((items) =>
      items.map((item) => (item.id === id ? { ...item, name } : item))
    );
  };

  return ...;
}

やったこと

RTK Queryを使ってデータを取得し、キャッシュする

Redux ToolkitにはRTK Queryというデータの取得とキャッシュに特化したツールがあります。APIから取得したデータをキャッシュしてくれるのに加えて、内部的にcreateAsyncThunkを使っているため任意のSliceextraReducersでactionをキャッチすることも可能です。endpointsに記述したquerymutationはhooksとして使うことができます。

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/' }),
  endpoints: (build) => ({
    // `api.endpoints.getItems.matchFulfilled`でキャッチできる
    getItems: build.query<Item[], void>({
      query: () => 'items',
    }),
    updateItems: build.mutation<void, Item[]>({
      query: (items) => ({
        url: `items`,
        method: 'PUT',
        body: items,
      }),
    }),
  }),
});

export const { useGetItemsQuery, useUpdateItemsMutation } = api;

default export api;

Custom Hookで操作をカプセル化する

データの取得と操作をCustom Hooksでカプセル化しました。RTK Queryのhooksで初期化し、CRUD操作はcreateEntityAdapterを使うことで実装を省略しています。

import { createEntityAdapter } from '@reduxjs/toolkit';
import { useCallback, useEffect, useState } from 'react';

export const useItems = () => {
  const { data = [] } = useGetItemsQuery();
  const [updateItemsMutation] = useUpdateItemsMutation();

  // `createEntityAdapter`はどうしても使いたかった
  const itemsAdapter = createEntityAdapter<Item>();
  const [itemsState, setItemsState] = useState(itemsAdapter.getInitialState());
  useEffect(() => {
    setItemsState(itemsAdapter.setAll(itemsState, data));
  }, [data]);

  const items = itemsAdapter.getSelectors().selectAll(itemsState);

  const updateItem = (item: Item) => {
    itemsAdapter.updateOne(itemsState, { id: item.id, changes: item });
  };

  const saveItems = useCallback(() => {
    updateItemsMutation(items);
  }, [items]);

  return { items, updateItem, saveItems } as const;
};

まとめ

今回はRedux Toolkitをすでに使っているという都合上RTK Queryを使いましたが、データをキャッシュしたい!hooksとして使いたい!という話であればSWRを使うのも手なのかなと思いました。
また、Custom Hooksは雑に使えて便利ですが、必要に応じてuseMemouseCallbackを使わないとパフォーマンスの問題も生じてきそうなのでzustandを使うのもよさそうです。
私は業務でReactに向き合うようになったのはここ2年くらいで、ここ最近はいい加減Reduxを完全に理解しなくてはと考えていましたが、無理にReduxを頑張らなくてもいろいろな選択肢があるのだなと思いました。

参考

スコープとライフタイムで考えるReact State再考
ReduxにおけるGlobal stateとLocal stateの共存
駄目になった Redux Thunk を hooks でリファクタリングする
「3種類」で管理するReactのState戦略
「ReactのカスタムHooksをカジュアルに使う」という記事を読んでカジュアルに使ってみた
ファーストクラスコレクション
Redux Architecture Guidelines


ウミトロンは、「持続可能な水産養殖を地球に実装する」というミッション実現に向けて、日々プロダクト開発・展開にチーム一丸となって邁進しています。
ウミトロンのニュースや活動状況を各種SNSで配信していますので、ぜひチェックいただき、来年も応援よろしくお願いします!
Facebook https://www.facebook.com/umitronaqtech/
X https://x.com/umitron
Instagram https://www.instagram.com/umitron.aqtech/
Linkedin https://www.linkedin.com/company/umitron

Discussion