Redux Toolkit v1.3.0 ではcreateAsyncThunk
とEntityAdapter
が追加されるようです。
https://github.com/reduxjs/redux-toolkit/pull/374
さっそく試してみましょう。
サンプル
https://codesandbox.io/s/ionic-react-redux-toolkit-sample
createAsyncThunk
非同期に対応したAction Creatorです。
pending
、fulfilled
、rejected
の3つのアクションを生成してくれます。
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準拠になっていて、pending
、fulfilled
、rejected
でそれぞれ型が違うようです。
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で使う場合はこんな感じです。
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を返すことを期待していますが、そのままでは利用できないため下記のいずれかの工夫が必要です。
-
Dispatch
の型をオーバーロードする
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
を定義して、
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
でエンティティ操作用のアダプターを生成します。
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
は自動的にメモ化してくれます。
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のメソッドの引数の順番が逆になっている点に注意
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内だけ書き方が変わるのは一貫性が崩れるので個人的には非推奨です。( 治安の悪いチームには必要かも?)
State
特に変わらず。
キー名(todos
等)はSelectorと合わせる必要があるので変数で持つのを強く推奨します。
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パターンのごちゃ混ぜ具合が怖い)
使う場合はコード量だけでなく型推論が効くか、テストをどう書くか等を調べる必要がありそうです。
まとめ
createAsyncThunk
とEntityAdapter
は良さそうでした。
- 公式提供の安心感
- 型による保守性
- 実装で迷わないオピニオン
今ならcreate-react-appのテンプレートにもRedux Toolkitを使ったものが追加されているので気軽に試せるかと思います。
$ npx create-react-app my-app --template redux
Action、SelectorとRedux用のHooksのインポートが煩雑な場合は、コメント欄にあるようにFacade等でStore周りをラップして使うなどの工夫をするのも良いかもしれません。
去年ThunkActionの型解決で苦労したので、今年はRedux Toolkitを推していきたいです。