LoginSignup
48
45

More than 3 years have passed since last update.

Redux Toolkit v1.3.0の新機能を使ってみよう

Last updated at Posted at 2020-02-24

Redux Toolkit v1.3.0 ではcreateAsyncThunkEntityAdapterが追加されるようです。
https://github.com/reduxjs/redux-toolkit/pull/374

さっそく試してみましょう。

サンプル

https://codesandbox.io/s/ionic-react-redux-toolkit-sample
sample

createAsyncThunk

非同期に対応したAction Creatorです。
pendingfulfilledrejectedの3つのアクションを生成してくれます。

src/store/todo/actions/todo.action.ts
import { createAsyncThunk } from '@reduxjs/toolkit';

import { todoService } from '../../../services';

export const fetchAllTodos = createAsyncThunk(
  'todos/fetchAll',
  async (arg: { offset?: number; limit?: number }) => {
    const { offset, limit } = arg;
    const result = await todoService.fetchAll(offset, limit);
    return { todos: result };
  }
);

export const fetchTodo = createAsyncThunk(
  'todos/fetch',
  async (arg: { id: string }) => {
    const { id } = arg;
    const result = await todoService.fetch(id);
    return { todo: result };
  }
);

createAsyncThunkが生成するアクションはFSA準拠になっていて、pendingfulfilledrejectedでそれぞれ型が違うようです。

interface PendingAction<ThunkArg> {
  type: string;
  meta: {
    requestId: string;
    arg: ThunkArg; // ここにActionの引数が入る
  };
}

interface FulfilledAction<ThunkArg, T> {
  type: string;
  payload: T; // fulfilled時のみ値が入る
  meta: {
    requestId: string;
    arg: ThunkArg;
  };
}

interface RejectedAction<ThunkArg> {
  type: string;
  error: {
    name?: string;
    message?: string; // new Error('hoge')の'hoge'が入る
    code?: string;
    stack?: string;
  } | any;
  meta: {
    requestId: string;
    arg: ThunkArg;
    aborted: boolean;
  };
}

Container Componentで使う場合はこんな感じです。

src/pages/todo/todo-list/containers/todo-list.container.tsx
import { unwrapResult } from '@reduxjs/toolkit';
import { useDispatch, useSelector } from 'react-redux';

import { todosSelector, fetchAllTodos } from '../../../../store/todo';

type Props = {
  offset: number;
  limit: number;
};

export const TodoListContainer: React.FC<Props> = props => {
  const { offset, limit } = props;
  const todos = useSelector(todosSelector);
  const dispatch = useDispatch();
  // const dispatch: AppDispatch = useDispatch();

  React.useEffect(() => {
    dispatch(fetchAllTodos({ offset, limit }))  // ActionをDispatch
      .then(unwrapResult)
      .then(payload => {
        console.log({ payload });
      })
      .catch(error => {
        console.log({ error });
      });
  }, [dispatch]);

  return <TodoList todos={todos} />;
};

unwrapResultを使うと成功時(fulfilled時)に.then(...)、失敗時(rejected時)に.catch(...)へと処理を分けてくれます。便利ですね。

この例ではThunkActionを受け取ったdispatch()がPromiseを返すことを期待していますが、そのままでは利用できないため下記のいずれかの工夫が必要です。

  1. Dispatchの型をオーバーロードする
@types/redux-thunk/index.d.ts
import { ThunkAction } from 'redux-thunk';

// Dispatch overload for redux-thunk
// https://github.com/reduxjs/redux-thunk/pull/278
declare module 'redux' {
  /*
   * Overload to add thunk support to Redux's dispatch() function.
   * Useful for react-redux or any other library which could use this type.
   */
  export interface Dispatch<A extends Action<any> = AnyAction> {
    <TReturnType, TState, TExtraThunkArg>(
      thunkAction: ThunkAction<TReturnType, TState, TExtraThunkArg, A>
    ): TReturnType;
  }
}

この型定義はredux-thunkのmasterにマージされているので、将来的にはいらなくなりそうです。
 
2. useDispatchをキャストする

公式ドキュメントに書かれてあるようにAppDispatchを定義して、

src/store/store.ts
export const store = configureStore(...);
export type AppDispatch = typeof store.dispatch;
// export const useAppDispatch = () => useDispatch<AppDispatch>();

useDispatchをキャストして使います。

const dispatch: AppDispatch = useDispatch();

毎回AppDispatchをインポートしないといけないので少々面倒ですね。

EntityAdapter

@ngrx/entityとほぼ同じです。

Entity

エンティティは以下のようなIDとデータのマップです。

export type EntityId = number | string;
export interface EntityState<T> {
  ids: EntityId[];
  entities: {
    [id: EntityId]: T
  };
}

State

EntityStateを継承してStateを作成し、createEntityAdapterでエンティティ操作用のアダプターを生成します。

src/store/todo/states/todo.state.ts
import { EntityState, createEntityAdapter } from '@reduxjs/toolkit';

import { Todo } from '../../../models';

export const featureKey = 'todos';

export interface TodoState extends EntityState<Todo> {
  isFetching: boolean;
  selectedId: string | null;
}

export const adapter = createEntityAdapter<Todo>();

export const initialState: TodoState = adapter.getInitialState({
  isFetching: false,
  selectedId: null
});

Selector

EntityAdapterのgetSelectors()でエンティティ用のセレクタを取得します。
createSelectorは自動的にメモ化してくれます。

src/store/todo/selectors/todo.selector.ts
import { createSelector } from '@reduxjs/toolkit';

import { TodoState, adapter, featureKey } from '../states';

const { selectAll, selectEntities } = adapter.getSelectors();

const featureStateSelector = (state: {[featureKey]: TodoState }) => state[featureKey];

const entitiesSelector = createSelector(
  featureStateSelector,
  selectEntities
);

export const isFetchingSelector = createSelector(
  featureStateSelector,
  state => state.isFetching
);

export const selectedIdSelector = createSelector(
  featureStateSelector,
  state => state.selectedId
);

export const todosSelector = createSelector(
  featureStateSelector,
  selectAll
);

export const todoSelector = createSelector(
  entitiesSelector,
  selectedIdSelector,
  (entities, id) => (id ? entities[id] || null : null)
);

NgRxのcreateFeatureSelectorも欲しいところ。

Reducer

ActionReducerMapBuilderで書く方のcreateReducerを使いましょう。型推論が効きます。

エンティティの操作はEntityAdapterのヘルパーを使います(ドキュメント)。

  • setAll
  • upsertOne
  • updateOne
  • removeOne

※ NgRxと違ってEntityAdapterのメソッドの引数の順番が逆になっている点に注意

src/store/todo/reducers/todo.reducer.ts
import { createReducer } from '@reduxjs/toolkit';

import { initialState, adapter } from '../states';
import { fetchAllTodos, fetchTodo } from '../actions';

export const reducer = createReducer(initialState, builder =>
  builder
    .addCase(fetchAllTodos.pending, state => {
      return { ...state, isFetching: true };
    })
    .addCase(fetchAllTodos.fulfilled, (state, action) => {
      const { todos } = action.payload;
      return adapter.setAll({ ...state, isFetching: false }, todos);
    })
    .addCase(fetchAllTodos.rejected, state => {
      return { ...state, isFetching: false };
    })
    .addCase(fetchTodo.pending, (state, action) => {
      const { id } = action.meta.arg;
      return { ...state, isFetching: true, selectedId: id };
    })
    .addCase(fetchTodo.fulfilled, (state, action) => {
      const { todo } = action.payload;
      return adapter.upsertOne({ ...state, isFetching: false }, todo);
    })
    .addCase(fetchTodo.rejected, state => {
      return { ...state, isFetching: false };
    })
);

Immerが入ってるのでstateに直接代入できますが、Reducer内だけ書き方が変わるのは一貫性が崩れるので個人的には非推奨です。(:thinking: 治安の悪いチームには必要かも?)

State

特に変わらず。

キー名(todos等)はSelectorと合わせる必要があるので変数で持つのを強く推奨します。

src/store/store.ts
import { configureStore, combineReducers } from '@reduxjs/toolkit';

import * as todo from './todo';

// Reducers
const reducer = combineReducers({
  todos: todo.reducer,
  //[todo.featureKey]: todo.reducer
});

// Store
export const store = configureStore({ reducer });

// Types
export type State = ReturnType<typeof reducer>;
export type AppDispatch = typeof store.dispatch;

ActionのPayloadにDate型が入るなどシリアライズ不可な場合は↓のようにチェックを外した状態でミドルウェアを登録します。

const middleware = getDefaultMiddleware({ serializableCheck: false });
export const store = configureStore({
  reducer,
  middleware
});

Slice

今回は使いませんでした。
(ducksパターンのごちゃ混ぜ具合が怖い:fearful:

使う場合はコード量だけでなく型推論が効くか、テストをどう書くか等を調べる必要がありそうです。

まとめ

createAsyncThunkEntityAdapterは良さそうでした。

  • 公式提供の安心感
  • 型による保守性
  • 実装で迷わないオピニオン

今ならcreate-react-appのテンプレートにもRedux Toolkitを使ったものが追加されているので気軽に試せるかと思います。

$ npx create-react-app my-app --template redux

Action、SelectorとRedux用のHooksのインポートが煩雑な場合は、コメント欄にあるようにFacade等でStore周りをラップして使うなどの工夫をするのも良いかもしれません。

去年ThunkActionの型解決で苦労したので、今年はRedux Toolkitを推していきたいです。

48
45
8

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
48
45