Redux - Toolkit
QuickStart
目的
- Redux Toolkit は、redux に関する下記の問題を解決するためのツール
- 初期セットアップが複雑すぎる
- 便利に使うには多くのパッケージをインストールする必要がある
- ボイラープレートが多すぎる
- 全てのケースはカバー出来なくても、大半のケースでコードを簡略化できるツールを目指している
create-react-app
やapollo-boost
の精神に感化されている
含まれるもの
configureStore()
createReducer()
createAction()
createSlice()
createAsyncThunk()
createEntityAdapter
createSelector
---reselect
のcreateSelector
を利便性のために再エクスポートしたもの
基本チュートリアル
configureStore
createStore()
のラッパー- reducer をオブジェクトとして与えた場合には
combineReducer()
が自動で呼ばれる - ミドルウェア群がデフォルトで追加される
- Redux DevTools
- redux-thunk
- Immutability check middleware --- store の値が直接改変されないよう監視する
- Serializability check middleware --- シリアライズ出来ない値()が store に入り込まないように監視する
// Before:
const store = createStore(counter);
// After:
const store = configureStore({
reducer: counter,
});
createAction
- 与えられた action type (文字列)を元に action creator を生成する
- 生成された action creator 関数は
toString()
メソッドを持つため、そのまま action type としても使用できる - 本当は
createActionCreator()
が正しい名前である
action type の取得方法は 2 つある
- オーバーライドされた
toString()
を使う(action creator をそのまま使う) .type
を使う
// Before
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';
function increment() {
return { type: INCREMENT };
}
function decrement() {
return { type: DECREMENT };
}
function counter(state = 0, action) {
switch (action.type) {
case INCREMENT:
return state + 1;
case DECREMENT:
return state - 1;
default:
return state;
}
}
// After
const increment = createAction('INCREMENT');
const decrement = createAction('DECREMENT');
function counter(state = 0, action) {
switch (action.type) {
// .typeを使う方法
case increment.type:
return state + 1;
// toStringを使う方法
case decrement:
return state - 1;
default:
return state;
}
}
createReducer
- slice reducer を作成して返す
- root reducer --- store 全体を管理する reducer (アプリ単位。多くの場合
combineReducers()
で作られる reducer) - slice reducer --- store の一部を管理する reducer (ドメイン単位。例:
store.todos
を管理する reducer) - case reducer --- 個々のアクションに対応する reducer (アクション単位。例:
ADD_TODO
を担当する reducer)
- root reducer --- store 全体を管理する reducer (アプリ単位。多くの場合
- action type から reducer へのルックアップテーブルという形で処理を記述できるようにする。これにより switch 文が不要になる。
- 裏側で immer が使われているため、state を直接書き換える形で値を改変することができることにより、簡潔な表記が可能になる
- デフォルトケースについては明記する必要はない
- TypeScript 環境では型推論の効く builder パターンでの記述を推奨
const increment = createAction('INCREMENT');
const decrement = createAction('DECREMENT');
// object記法
// - action変数に手動で型付けが必要
// - アクション名を参照するときに`.type`が必要になる場合あり
const counter = createReducer(0, {
// toStringを使う方法
[increment]: (state, action) => {},
// .typeを使う方法
[decrement.type]: (state, action) => {},
});
// builder記法
// - action変数には自動で型推論が効いている
// - アクション名を参照するときに`.type`は不要
const counter = createReducer(0, (builder) =>
builder
.addCase(increment, (state, action) => {})
.addCase(decrement, (state, action) => {}),
);
createSlice
- slice オブジェクトを作成する(state の一部を管理する責務をもつオブジェクト)
- 下記を一括で生成する
- reducer
- action creators (reducer オブジェクトのキー名が関数名となる)
- action type strings (slice 名 + reducer オブジェクトのキー名)
- 引数
- slice 名
- store の初期値
- reducers オブジェクト
- 多くの場合
createAction()
やcreateReducer()
を使うまでもなくcreateSlice()
だけで事足りる
const counterSlice = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increment: (state) => (state += 1),
decrement: (state) => (state -= 1),
},
});
const { actions, reducer } = counterSlice;
const { increment, decrement } = actions;
// createSlice()の返値は下記のような形式になる
// {
// name: "todos",
// reducer: (state, action) => newState,
// actions: {
// addTodo: (payload) => ({type: "todos/addTodo", payload}),
// toggleTodo: (payload) => ({type: "todos/toggleTodo", payload})
// },
// caseReducers: {
// addTodo: (state, action) => newState,
// toggleTodo: (state, action) => newState,
// }
// }
extraReducers
- action creator を自動的に作成したくない場合に使用する
- action creator が作成されない点を除いて
reducers
と同じ働きをする - TypeScript 環境では型推論の効く 前述の builder 記法での記述を推奨
const counterSlice = createSlice({
name: 'counter',
initialState: 0,
// object記法
extraReducers: {
[increment]: (state, action) => {},
[decrement]: (state, action) => {},
},
// builder記法
extraRducers: (builder) =>
builder
.addCase(increment, (state, action) => {})
.addCase(decrement, (state, action) => {}),
});
注意点
createSlice や ducks パターンで注意すること
- action type は単一の slice でのみ使うのではなく、複数の slice で共有されるべきものであることを認識して設計を行う
- 例えば、ユーザがログアウトしたよー、というアクションは様々な slice ので処理されうる
- action type の循環参照に注意する。対処法は以下の通り。
- 例えば 2 つ異なる slice がお互いの action (type) を相互に参照している場合
- 該当する action を新たなファイルに移行して
createAction
で作成する - 作成した action type を 2 つの slice においてインポートし
extraReducer
に処理を記載する
createAsyncThunk
- createAsyncThunk を使うと、thunk を書くときにありがちな冗長なコードを簡略化できる。
- 与えられた action type (文字列)と Promise を返す関数を元に、thunk を生成する
- thunk は Promise の結果により
pending/fulfilled/rejected
のいずれかの action type を送出する - 生成された thunk は
thunk.fulfilled
等の形式で action type として利用できる
export const fetchIssue = createAsyncThunk(
'issues/fetchIssue', // アクション名は手動で指定する
async (arg, thunkAPI) => {
// 引数は1つしか使えないので、複数必要な場合はこのようにオブジェクトにする必要がある
const { org, repo, issueId } = arg;
const issues = await getIssue(org, repo, issueId);
return issues;
},
);
const issues = createSlice({
name: 'issues',
initialState: issuesInitialState,
reducers: {},
// action creatorは作成する必要がないので、extraReducersを使う
extraReducers: {
[fetchIssue.pending.type]: (state, action) => {},
// action.payloadにissuesが入っている
[fetchIssue.fulfilled.type]: (state, action) => {},
[fetchIssue.rejected.type]: (state, action) => {},
},
});
// コンポーネントで
dispatch(fetchIssue({ org: 1, repo: 2, issueId: 3 }));
なお、ThunkAPI
は以下のような値を持つ
interface ThunkAPI {
dispatch: Function;
getState: Function;
extra?: any;
requestId: string;
signal: AbortSignal;
}
unwrapResult
- 通常の thunk action と異なり、createAsyncThunk で作成した thunk action は常に action
{type, payload}
を返す。 - この挙動を避けて、元のプロミスが返す値を取得したい場合は、
unwrapResult
を使う。
import { unwrapResult } from '@reduxjs/toolkit';
const onClick = () => {
dispatch(fetchUserById(userId))
.then(unwrapResult)
.then((fetchedUser) => {});
};
createEntityAdapter
- state を正規化して管理している場合に役立つツール
- entity を追加・更新・削除するための、定型的な reducers を簡単に作成できる
reselect
でメモ化された、定型的な selectors を簡単に作成できる
entity のデータ構造
createEntityAdapter
を使うと、個々の entity はids
とentities
で管理されるようになる
// stateの構成例
{
entities {
books: {
ids: [1,2,3]
entities: {1:{},2:{},3:{}}
}
authors: {
ids: [1,2,3]
entities: {1:{},2:{},3:{}}
}
}
}
アダプタの作成
type Book = { bookId: string; title: string };
const booksAdapter = createEntityAdapter<Book>({
// idフィールドに`id`キー以外のものを使いたい場合は下記のように明示的に指定する
selectId: (book) => book.bookId,
// 並べ替えの方法を指定したい場合
sortComparer: (a, b) => a.title.localeCompare(b.title),
});
initialState の作成
getInitialState()
は単に{ids:[],entities:{}}
を返す
const initialState = booksAdapter.getInitialState();
case reducers の利用
アダプタは entity を CRUD するための便利な関数を持つ。シグネチャはメソッドごとに微妙に異なるので、都度ドキュメントを参照すること。
- addOne
- addMany
- setAll
- removeOne
- removeMany
- updateOne
- updateMany
- upsertOne
- upsertMany
const booksSlice = createSlice({
name: 'books',
initialState,
reducers: {
// アダプタのreducerをそのまま使用する場合
bookAdded: booksAdapter.addOne,
// アダプタのreducerを手動で呼ぶ場合
booksReceived(state, action) {
booksAdapter.setAll(state, action.payload.books);
},
},
});
セレクタ
- selectIds
- selectEntities
- selectAll
- selectTotal
- selectById
// グローバル化されていないセレクタには、entityだけを明示的に渡す必要がある
const nonGlobalizedSelectors = booksAdapter.getSelectors();
const allBooks = useSelector((state) =>
nonGlobalizedSelectors.selectAll(state.enties.books),
);
// グローバル化されたセレクタには、root stateをそのまま渡せる
// つまり、useSelectorにそのまま渡せる
const globalizedSelectors = booksAdapter.getSelectors<RootState>(
(state) => state.books,
);
const bookIds = useSelector(globalizedSelectors.selectAll);
中級チュートリアル
ducks パター ン
redux コミュニティの慣例
- 単一ファイルに action creators と reducers を記載する
- reducer をデフォルトエクスポートする
- action creators を named export する
おすすめのフォルダ構成
- ほとんどの場合において "feature folder" approach が有効であることが確認されている
- 機能やドメインごとにフォルダ分けする方法
- ファイルタイプ(actions, reducers, containers, components)ごとにフォルダを分ける方法は見通しが悪化しがち
- 参考
フォルダ構成例
このプロジェクトを参考にするとよいかも
- src
- api
- api を叩く関数など
- app
App.tsx|css
,rootReducer.ts
,store.ts
など、アプリの核となるファイル
- components
- 再利用可能なコンポーネント
- 画面や機能に依存しないコンポーネント
- features
- 機能ごと、画面ごと、ドメインごとにフォルダを作る
- コンポーネント、css、スライス(actions, reducers)、セレクタ、テストファイルなどを含む
- utils
- api
Flux Standard Actions
- アクションは
{type:string, payload: any}
の形式であるべき - redux-toolkit ではデフォルトでその形式になる
payload に手を加えるには
createAction()
やcreateSlice()
を使って生成された action creators は、与えた引数をそのままaction.payload
として送出する- 与えた引数に何らかの処理を行ってから(prepare してから) payload を作成したい場合は下記のようにする
// createActionの場合は第2引数に`prepare callback`を記載する
const addTodo = createAction('ADD_TODO', text => {
return {
payload: { id: uuid(), text }
}
})
// createSliceの場合はreducerとprepare functionを分けて記述する
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
addTodo: {
reducer(state, action) {
const { id, text } = action.payload
state.push({ id, text, completed: false })
},
prepare(text) {
return { payload: { text, id: 1 + 2 + 3 } }
}
}
}
}
セレクタの最適化(sharrowEqual を使う場合)
- 下記の場合、data は毎回新しいオブジェクトになる。すなわち再描写がかかる。
- これは useSelector の比較方法が reference equality だからである。なお、connect の比較方法は shallow equality である。
- reference equality --- セレクタが返したオブジェクト「自体」のアドレスが同一であるか
- shallow equality --- セレクタが返したオブジェクトの「1 階層目のキーの種類とその参照先アドレス」が同一であるか
- 第 2 引数に shallowEqual を渡すことで比較方法を変更できる
const data = useSelector(
(state) => ({
commentsLoading: state.comments.loading,
commentsError: state.comments.error,
comments: state.comments.commentsByIssue[issueId],
}),
// shallowEqual,
);
セレクタの最適化(reselect を使う場合)
- shallowEqual を使ったとしても再描写がかかってしまう場合がある。例えば下記の
getVisibleTodos()
のうち.filter()
された結果については、必ず新しい(=参照の異なる)配列として生成される - このように state の一部をフィルタして抜き出すなどするときなどは、
reselect
を使って適宜メモ化すること reselect
を使うと.filter()
した結果もメモ化され、同じ参照のオブジェクトとして取得できる
import { connect, useSelector } from 'react-redux'
+import { createSelector } from '@reduxjs/toolkit'
import { toggleTodo } from 'features/todos/todosSlice'
import TodoList from '../components/TodoList'
import { VisibilityFilters } from 'features/filters/filtersSlice'
-const getVisibleTodos = (todos, filter) => {
- switch (filter) {
- case VisibilityFilters.SHOW_ALL:
- return todos
- case VisibilityFilters.SHOW_COMPLETED:
- return todos.filter(t => t.completed)
- case VisibilityFilters.SHOW_ACTIVE:
- return todos.filter(t => !t.completed)
- default:
- throw new Error('Unknown filter: ' + filter)
- }
-}
+const selectTodos = state => state.todos
+const selectFilter = state => state.visibilityFilter
+const selectVisibleTodos = createSelector(
+ [selectTodos, selectFilter],
+ (todos, filter) => {
+ switch (filter) {
+ case VisibilityFilters.SHOW_ALL:
+ return todos
+ case VisibilityFilters.SHOW_COMPLETED:
+ return todos.filter(t => t.completed)
+ case VisibilityFilters.SHOW_ACTIVE:
+ return todos.filter(t => !t.completed)
+ default:
+ throw new Error('Unknown filter: ' + filter)
+ }
+ }
+)
const mapStateToProps = state => ({
- todos: getVisibleTodos(state.todos, state.visibilityFilter)
+ todos: selectVisibleTodos(state)
})
又はhookの場合、
+ const todos = useSelector(selectVisibleTodos)
上級チュートリアル
dispatch()の戻り値
dispatch()
の戻り値は引数の戻り値と等しくなる。例えば:プレーンなaction creator()
を与えた場合 --- action creator が return した値(通常は{type, payload}
)thunk action creator()
を与えた場合 --- thunk が return した値(よって、thunk が async なら Promise が戻る)
HMR
- HMR が利用できる環境では
module.hot
が存在するので、これを使って再描写を行う - 再描写の方法は
accept()
のコールバックに個別に記載する - create-react-app ではデフォルトでは HMR ではなくフルリロードが行われる
module.hot.accept(
dependencies, // 監視するファイル
callback, // ファイルが変更されたときに何をするか
);
store の型
mapState
,useSelector
,getState
などで store の型を利用するにはReturnType
という TS のビルトイン型を使う- 下記のようにすることで自動的に store の型が最新 に保たれる
// app/rootReducer.ts
import { combineReducers } from '@reduxjs/toolkit';
const rootReducer = combineReducers({});
// rootReducerが返す値の型をstoreの型として利用する
// 型はエクスポートしておき、各所で利用する
export type RootState = ReturnType<typeof rootReducer>;
export default rootReducer;
store のセットアップ
app/store.ts
のセットアップ例
インポート関連
import { Action, configureStore } from '@reduxjs/toolkit';
import {
TypedUseSelectorHook,
useDispatch as rawUseDispatch,
useSelector as rawUseSelector,
} from 'react-redux';
import { ThunkAction } from 'redux-thunk';
import rootReducer, { RootState } from './rootReducer';
ストアの作成
const store = configureStore({
reducer: rootReducer,
});
export default store;
HMR の設定(redux 関連)
// reducerが更新されたときはHMRする
// (store.replaceReducerを使って、reducerだけを入れ替える)
if (process.env.NODE_ENV === 'development' && module.hot) {
module.hot.accept('./rootReducer', () => {
const newRootReducer = require('./rootReducer').default;
// const newRootReducer = (await import('./rootReducer')).default
store.replaceReducer(newRootReducer);
});
}
thunk action の型定義
- thunk action creator の戻り値の型(= thunk action の型)として使用する
createAsyncThunk
のみを使用し、手動で thunk を作成しない場合は、この作業は不要
export type MyThunkAction<R = Promise<any>> = ThunkAction<
R, // thunk actionの戻り値(結果)の型。ほとんどの場合Promiseになる。
RootState, // root stateの型
unknown, // thunk actionの第3引数の型(拡張用、通常は使わない)
Action<string> // action.typeの型
>;
// コンポーネントでの使用例
export const fetchIssues = (): MyThunkAction => async (dispatch) => {};
dispatch の型定義
- 素の
useDispatch
を型付けして再利用可能にしておく - これを行わないと
dispatch().then()
したときに型エラーとなる - https://qiita.com/hiroya8649/items/73d80a52636a787fefa5
// 暗黙的に ThunkDispatch 型になる
type MyDispatch = typeof store.dispatch;
export const useDispatch = () => rawUseDispatch<MyDispatch>();