👩‍🎤

Redux Toolkitについて[createEntityAdapter推し]

2021/06/25に公開約12,300字

はじめに

React初学者がReduxを使うには学習コストが高いと言われて避けられる傾向にある気もしますが、Redux Toolkitの登場と進化で気軽に使えるようになってきているのでぜひ使ってみてほしいところ。

その中でも推し機能、createAsyncThunkとcreateEntityAdapterについての簡単なご紹介です。

ちなみに最近v1.6から参加のニューメンバーRTK Queryが推しメンに追加されました🎉

https://zenn.dev/himorishige/articles/9d47f3d8b50342

Reduxとは

Reduxは、「Action」と呼ばれるイベントを用いて、アプリケーションの状態(state)を管理・更新するためのライブラリです。React以外にも利用されていますがReactとともに用いられることが多いようです。
Fluxというデータフローの概念を利用して設計されており、主にAction、Store、Reducerの要素で構成されています。

Reactでは各コンポーネントがpropとstateを持っていますが、アプリケーションの規模が大きくなりにつれ、コンポーネント間のやり取りが大変になってきます。俗にprop drilling problemとも呼ばれています。
Reduxを用いるとStoreという唯一の箱のようなものでstateを管理するため、どこからでも更新、参照できるという大きなメリットを持っています。

Reduxの大まかな流れとしては、

  • ActionはActionCreatorで作られます。
  • Storeではstateを管理しています。
  • ReducerはActionとstateから新しいstateを作成します。

まず、ユーザーは行いたいActionを、Storeに対してdispatchというメソッドを用いて送信します。
次にReducerが、Storeが受け取ったActionにしたがってstateから新しいstateを作成して返します。
必ず新しいstateのみを返すことで、Reduxではアプリケーションの中で状態がいつ、どのように更新されたかを容易に把握することができるようになっています。

タイムトラベルデバッギングとも言われているようで、デベロッパーツールを用いると時間軸に沿った動きをデバッグすることができます。

ツールとしてはとても有益な反面、ルールが厳格で手続き上記載する内容も冗長になることで学習コスト、運用コストもかかるため最近では脱Redux論もさかんに議論されているようです。

Redux Toolkit

そんな中公式のRedux Toolkitが2019/10にリリースされました。
Redux ToolkitはReduxをより簡潔に記述するためのツールです。公式ページに掲載されているRedux Style GuideでもToolkitの利用を推奨しています。

https://redux.js.org/style-guide/style-guide

Redux Toolkitを使うメリット

初期設定などがほとんど不要になる

Redux DevTools Extension(デバッギングツール)の設定や非同期通信を扱うReduxThunkなどのミドルウェアが同梱されているため、設定不要で利用することができます。

コード量(ファイル数?)が少なくなる

Action、ActionCreator、ReducerがcreateSliceというひとつの記述でまとめて記載することができます。
一つのファイルのコード行数は増えるかも。。

stateのイミュータブルを意識なくてよい

Reducerの中で新しいstateを返す際には必ずイミュータブルな値となるようにしなければなりませんでしたが、Toolkitでは内部で自動的にイミュータブルな値として処理を行ってくれます。

TypeScriptの型が効く

Toolkitでは多くの型が設定されいるので型による補完と制御の恩恵を受けることができます。
create-react-appのReduxテンプレートを使うとそのまま利用できる部分も多くとても便利です。

https://github.com/reduxjs/cra-template-redux

createAsyncThunkとcreateEntityAdapterが便利

Toolkitの1.3から追加されたcreateAsyncThunkとcreateEntityAdapterがとても便利です。

createAsyncThunk

https://redux-toolkit.js.org/api/createAsyncThunk

非同期処理に対応したActionCreatorを生成する関数で、下記3種の状態を簡単に管理することができます。

  • pending: 非同期処理中
  • fulfilled: 非同期処理の成功時
  • rejected: 非同期処理の失敗時

createEntityAdapter

https://redux-toolkit.js.org/api/createEntityAdapter

型情報をもとにエンティティ操作用のAdapterを生成し、CRUD(create, read, update, delete)操作の機能を提供してくれる関数です。
データベースを扱うような形で処理を行うことができるようになります。

{
  id: 1,
  title: 'create-react-app直後にやる環境構築の備忘録',
  createdAt: 1620804168398,
  updatedAt: 1621064460898,
  body: '本文テキスト',
  image: '/assets/images/dummy01.jpeg',
  like: 167,
  publish: false
} 

例えば上記のような形式を下記のような形に自動的に変換して管理してくれます。

{
  postsEntity: {
    ids: [
      1,
      2
    ],
    entities: {
      '1': {
        id: 1,
        title: 'create-react-app直後にやる環境構築の備忘録',
        createdAt: 1620804168398,
        updatedAt: 1621064460898,
        body: '本文テキスト',
        image: '/assets/images/dummy01.jpeg',
        like: 167,
        publish: false
      },
      '2': {
        id: 2,
        title: 'Reactを使ったお天気アプリを作成してみた',
        createdAt: 1620804168398,
        updatedAt: 1621070951011,
        body: '本文テキスト2',
        image: '/assets/images/dummy01.jpeg',
        like: 112,
        publish: true
      },
    },
  }
}

データのエンティティ化(ノーマライズ)についてのは下記でサンプルをベースに解説されています。

https://redux.js.org/recipes/structuring-reducers/normalizing-state-shape

実装例

少々長いのですが実装を行ったサンプルになります。
まずはベースとなる型情報。ブログの投稿をイメージしています。

src/types/index.ts
export type Post = {
  id: string;
  createdAt: number;
  updatedAt?: number;
  title: string;
  body: string;
  image: string;
  like: number;
  publish: boolean;
};

export type Posts = Post[];

以下投稿一覧の取得や更新、削除を行うSliceのサンプルです。

createEntityAdapterで作成したAdapterに対しては下記のメソッドが利用できます。

  • addOne 1件追加
  • addMany 複数追加
  • setAll 全件追加上書き
  • removeOne 1件削除
  • removeMany 複数削除
  • removeAll 全件削除
  • updateOne 1件を更新
  • updateMany 複数更新
  • upsertOne 1件を更新、存在しない場合は追加
  • upsertMany 複数を更新、存在しない場合は追加

が利用できます。

src/features/posts/postsEntitySlice.ts
import {
  createAsyncThunk,
  createEntityAdapter,
  createSlice,
  PayloadAction,
} from '@reduxjs/toolkit';
import { RootState } from 'src/app/store';
import axios from 'axios';

import { Post, Posts } from 'src/types';

export type PostsState = {
  posts: Posts;
  status: 'idle' | 'loading' | 'failed';
  message: string;
};

/**
 * entity用のアダプターを生成
 */


const postsAdapter = createEntityAdapter<Post>({
  // 記事のデフォルトの並び順を降順に変更
  selectId: (post) => post.id,
  sortComparer: (a, b) => {
    if (a.id < b.id) {
      return 1;
    } else {
      return -1;
    }
  },
});

const postInitialEntityState = postsAdapter.getInitialState({
  // 型以外に設定したいものはここで用意
  status: 'idle',
  message: '',
});

// APIのエンドポイント
const URL = process.env.REACT_APP_JSON_SERVER_URL || 'http://localhost:3000';

/**
 * 投稿一覧を取得する
 */

export const fetchEntityPosts = createAsyncThunk('posts/fetchEntityPosts', async (_, thunkApi) => {
  const response = await axios.get<Posts>(`${URL}/posts`).catch((err) => {
    thunkApi.rejectWithValue(err); // thunkApiを利用してエラーメッセージなどをreducerにわたすことができる
    throw err;
  });
  return response.data;
});

/**
 * 投稿を追加する
 */

export const addEntityPost = createAsyncThunk(
  'posts/addEntityPost',
  async (post: Omit<Post, 'id'>, thunkApi) => {
    const response = await axios.post<Post>(`${URL}/posts`, post).catch((err) => {
      thunkApi.rejectWithValue(err);
      throw err;
    });
    return response.data;
  },
);

/**
 * 投稿を編集する
 */

export const updateEntityPost = createAsyncThunk(
  'posts/updateEntityPost',
  async (post: Post, thunkApi) => {
    const response = await axios.put<Post>(`${URL}/posts/${post.id}`, post).catch((err) => {
      thunkApi.rejectWithValue(err);
      throw err;
    });
    return response.data;
  },
);

/**
 * 投稿を削除する
 */

export const deleteEntityPost = createAsyncThunk(
  'posts/deleteEntityPost',
  async (postId: string, thunkApi) => {
    const response = await axios
      .delete<Post>(`${URL}/posts/${postId}`, {
        data: { id: postId },
      })
      .catch((err) => {
        thunkApi.rejectWithValue(err);
        throw err;
      });
    return { data: response.data, postId };
  },
);

/**
 * 該当する投稿IDにいいねを1プラスする
 */

export const putLikes = createAsyncThunk('posts/putLikes', async (postData: Post, thunkApi) => {
  const response = await axios
    .put<Post>(`${URL}/posts/${postData.id}`, {
      ...postData,
      like: postData.like + 1,
    })
    .catch((err) => {
      thunkApi.rejectWithValue(err);
      throw err;
    });

  return { id: response.data.id, like: response.data.like };
});

/**
 * 公開・非公開を切り替える
 */

export const togglePublish = createAsyncThunk(
  'posts/togglePublish',
  async (postData: Post, thunkApi) => {
    const response = await axios
      .put<Post>(`${URL}/posts/${postData.id}`, {
        ...postData,
        publish: !postData.publish,
      })
      .catch((err) => {
        thunkApi.rejectWithValue(err);
        throw err;
      });
    return { id: response.data.id, publish: response.data.publish };
  },
);

export const postsEntitySlice = createSlice({
  name: 'postsEntity',
  initialState: postInitialEntityState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchEntityPosts.pending, (state) => {
        // ローディング中
        state.status = 'loading';
        state.message = '';
      })
      .addCase(fetchEntityPosts.rejected, (state, action) => {
        // 失敗
        state.status = 'failed';
        if (action.error.message) {
          state.message = action.error.message;
        }
      })
      .addCase(fetchEntityPosts.fulfilled, (state, action) => {
        // 成功
        state.status = 'idle';
        // 取得した投稿全件をstoreに登録
        postsAdapter.setAll(state, action.payload);
      })
      .addCase(addEntityPost.pending, (state) => {
        state.status = 'loading';
        state.message = '';
      })
      .addCase(addEntityPost.rejected, (state, action) => {
        state.status = 'failed';
        if (action.error.message) {
          state.message = action.error.message;
        }
      })
      .addCase(addEntityPost.fulfilled, (state, action) => {
        state.status = 'idle';
        // 1件をstoreに新規登録
        postsAdapter.addOne(state, action.payload);
      })
      .addCase(updateEntityPost.pending, (state) => {
        state.message = '';
      })
      .addCase(updateEntityPost.rejected, (state, action) => {
        if (action.error.message) {
          state.message = action.error.message;
        }
      })
      .addCase(updateEntityPost.fulfilled, (state, action: PayloadAction<Post>) => {
        state.status = 'idle';
        const { id, ...updateData } = action.payload;
        // 1件をidで指定して更新
        postsAdapter.updateOne(state, {
          id: id,
          changes: { ...updateData },
        });
      })
      .addCase(deleteEntityPost.pending, (state) => {
        state.status = 'loading';
        state.message = '';
      })
      .addCase(deleteEntityPost.rejected, (state, action) => {
        state.status = 'failed';
        if (action.error.message) {
          state.message = action.error.message;
        }
      })
      .addCase(deleteEntityPost.fulfilled, (state, action) => {
        state.status = 'idle';
        postsAdapter.removeOne(state, action.payload.postId);
      })
      .addCase(putLikes.pending, (state) => {
        state.message = '';
      })
      .addCase(putLikes.rejected, (state, action) => {
        if (action.error.message) {
          state.message = action.error.message;
        }
      })
      .addCase(putLikes.fulfilled, (state, action: PayloadAction<{ id: string; like: number }>) => {
        state.status = 'idle';
        // 1件のlikeという項目のみ更新
        postsAdapter.updateOne(state, {
          id: action.payload.id,
          changes: { like: action.payload.like },
        });
      })
      .addCase(togglePublish.pending, (state) => {
        state.message = '';
      })
      .addCase(togglePublish.rejected, (state, action) => {
        if (action.error.message) {
          state.message = action.error.message;
        }
      })
      .addCase(
        togglePublish.fulfilled,
        (state, action: PayloadAction<{ id: string; publish: boolean }>) => {
          // 1件のpublishという項目のみ更新
          state.status = 'idle';
          postsAdapter.updateOne(state, {
            id: action.payload.id,
            changes: { publish: action.payload.publish },
          });
        },
      );
  },
});

export default postsEntitySlice.reducer;

// 全件取得用のselector
export const selectPosts = postsAdapter.getSelectors<RootState>((state) => state.postsEntity);

// 単一項目取得用のselector
export const selectStatus = (state: RootState) => state.postsEntity.status;
export const selectMessage = (state: RootState) => state.postsEntity.message;

コンポーネント側では下記のように利用できます。
またuseSelectorやuseDispatchについても下記のように型を扱える形で利用が推奨されています。

src/app/hooks.ts(reduxテンプレート)
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

コンポーネント側ではusuSelectorからAdapterに対して下記メソッドでデータを取得することができます。

  • selectIds idの一覧を配列で取得
  • selectEntities entitiesオブジェクトを取得
  • selectAll 全件の配列を取得
  • selectTotal 件数を取得
  • selectById 該当するidに紐づくデータを取得

selectAllとselectByIdが一番利用することになるかと思います。

コンポーネント側
// 全件を取得
const posts = useAppSelector(selectPosts.selectAll);

// idを指定して取得
const post = useAppSelector((state) => selectPosts.selectById(state, postId));

このような形でエンティティ化されたものから、意識せずに通常の配列の形に変換されて変数へ格納することが可能です。
またcreateAsyncThunkでの実行結果は下記のようにコンポーネント上でも参照することができます。

コンポーネント側
const resultAction = await dispatch(fetchEntityPosts());
// 失敗時であればrejected、
// 成功時、ローディング時はそれぞれfullfilled、pendingとmatchさせることで判別が可能
if (fetchEntityPosts.rejected.match(resultAction)) {
  alert('データの取得に失敗しました。');
}      

さいごに

Reduxについては公式サイトのドキュメントやテンプレートが最近大きくアップデートされています。読み直すたびに新しい気付きがあるので、ぜひ参考にしてみてください。

関連記事

https://zenn.dev/himorishige/articles/9d47f3d8b50342
GitHubで編集を提案

Discussion

ログインするとコメントできます