はじめに
「Reduxはボイラープレートが多くて面倒」「action typeの定数、action creator、reducerと 同じような処理を3箇所に書かされる」——そんなイメージを持っている方は多いのではないでしょうか。 実際、初期のReduxはその通りで、単純なカウンターを作るだけでも何十行ものコードが必要でした。
しかし現在のReduxは、公式が「Redux Toolkit(RTK)」という標準ツールキットを前面に押し出しており、 かつての「面倒なReduxの姿」とはかなり違うものになっています。この記事では、 昔ながらのReduxの姿にも触れつつ、今のReduxがどれだけシンプルに書けるのかを実例で見ていきます。
Reduxとは
Reduxは、アプリケーション全体のstateを1つのstoreに集約し、 「stateは常にactionをディスパッチすることでしか変更できない」という 一方向のデータフローで管理するJavaScriptライブラリです。 React専用ではなく、Vueや素のJavaScriptからも利用できますが、 実際にはReactとの組み合わせ(React-Redux)で使われることがほとんどです。
主な特徴
- 予測可能なstate管理 - state変更は必ずactionという「何が起きたか」を表すオブジェクトを経由するため、変更履歴を追いやすい
- 単一のstore - アプリ全体のstateが1箇所に集約され、どのコンポーネントからも同じデータを参照できる
- Redux Toolkitによる簡潔さ - 公式が推奨する
createSliceやconfigureStoreを使えば、従来必要だったボイラープレートの大半が不要になる - 強力な開発者ツール - Redux DevToolsによって、actionの履歴やstateの変化をタイムトラベルしながらデバッグできる
インストール
React環境で使う場合は、Redux本体とReact-Redux、そしてRedux Toolkitを合わせて導入するのが現在の標準です。
npm install @reduxjs/toolkit react-redux
yarn add @reduxjs/toolkit react-redux
pnpm add @reduxjs/toolkit react-redux
Reduxの基本的な使い方を動かす
まずはReact抜きで、Redux単体の最小構成を見てみましょう。createStoreでstoreを作り、
dispatchでactionを送り、subscribeで変化を監視するという、Reduxの根幹となる流れです。
要点だけを抜き出すと、次のようになります。
import { createStore } from 'redux'
function counterReducer(state = { count: 0 }, action) {
switch (action.type) {
case 'counter/incremented':
return { count: state.count + 1 }
case 'counter/decremented':
return { count: state.count - 1 }
default:
return state
}
}
const store = createStore(counterReducer)
store.subscribe(() => {
console.log('state changed:', store.getState())
})
store.dispatch({ type: 'counter/incremented' })
実際にブラウザ上で動かして確かめられるのが次のサンプルです。
store.dispatchに渡しているのがactionです。reducerは現在のstateとactionを受け取り、
「新しいstate」を返す純粋関数として書きます。このシンプルな仕組みが、
複雑なアプリでも予測可能なstate管理を実現しているReduxの土台です。試しにcounterReducerへ
case 'counter/reset': return { count: 0 }を追加し、
store.dispatch({ type: 'counter/reset' })を呼び出す行を足してみてください。
actionを1つ増やすだけでstoreの挙動を拡張できることが分かります。
実践的なユースケース
Redux Toolkitのslice機能で書く
素のReduxでは、action type定数・action creator・reducerを個別に書く必要がありましたが、
Redux ToolkitのcreateSliceを使えばこれらを1つにまとめられます。
さらにreducer内では、内部的にImmerが使われているため「stateを直接書き換える」ような
記述をしても、実際にはイミュータブルな更新が行われます。要点だけを抜き出すと、
次のようになります。
import { createSlice, configureStore } from '@reduxjs/toolkit'
const counterSlice = createSlice({
name: 'counter',
initialState: { count: 0 },
reducers: {
incremented: (state) => {
state.count += 1 // Immerにより内部的にはイミュータブルな更新になる
},
incrementedBy: (state, action) => {
state.count += action.payload
},
},
})
const store = configureStore({ reducer: counterSlice.reducer })
store.dispatch(counterSlice.actions.incremented())
console.log(store.getState()) // { count: 1 }
実際にブラウザ上で動かして確かめられるのが次のサンプルです。
state.count += 1のように直接代入しているにもかかわらず、実際にはイミュータブルな
新しいstateが生成されています。action creatorもcounterSlice.actionsから
自動で生成されるため、手書きするコード量が大幅に減っています。試しにreducersへ
reset: (state) => { state.count = 0 }を追加し、
store.dispatch(counterSlice.actions.reset())を呼び出す行を足してみてください。
Redux ToolkitのcreateSliceはreducerを1つ増やすだけで、対応するaction creatorも
自動的に増えることが確認できます。
ReduxをReactコンポーネントと連携する
実際のアプリでは、React-Reduxが提供するuseSelectorとuseDispatchフックを使って
コンポーネントからstoreにアクセスします。Providerでアプリ全体をラップすることで、
どの階層のコンポーネントからでもstoreを参照できるようになります。要点だけを抜き出すと、
次のようになります。
import { Provider, useSelector, useDispatch } from 'react-redux'
function TodoApp() {
// useSelectorでstoreから必要な部分だけを取り出す
const todos = useSelector((state) => state.todos)
const dispatch = useDispatch()
return (
<ul>
{todos.map((todo) => (
<li onClick={() => dispatch(todosSlice.actions.toggled(todo.id))}>
{todo.text}
</li>
))}
</ul>
)
}
// Providerでラップしたコンポーネント配下ならどこからでもstoreを参照できる
createRoot(document.getElementById('app')).render(
<Provider store={store}>
<TodoApp />
</Provider>
)
実際にブラウザ上で動かして確かめられるのが次のサンプルです。
useSelectorはstoreの中から必要な部分だけを取り出し、その値が変わったコンポーネントだけを
再レンダリングします。useDispatchで取得したdispatch関数にactionを渡すだけで、
storeの更新とUIの再描画が自動的につながります。試しにtodosSliceのreducersへ
removed: (state, action) => state.filter((t) => t.id !== action.payload)を追加し、
一覧に削除ボタンを置いてdispatch(todosSlice.actions.removed(todo.id))を呼ぶ処理を
足してみてください。useSelectorが参照するstateが変わるたび、Reduxと連携した
コンポーネントが自動で再描画されることが確認できます。
Redux ToolkitのcreateAsyncThunkで非同期処理を扱う
API通信のような非同期処理は、Redux単体だとミドルウェアの追加設定が必要でしたが、
Redux ToolkitのcreateAsyncThunkを使えば、非同期処理の「開始・成功・失敗」という
3つの状態を自動的にactionとして扱えます。要点だけを抜き出すと、次のようになります。
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
const fetchUser = createAsyncThunk('user/fetch', async (userId) => {
const res = await fakeApi(userId)
return res
})
const userSlice = createSlice({
name: 'user',
initialState: { data: null, status: 'idle' },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.status = 'loading'
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'succeeded'
state.data = action.payload
})
},
})
実際にブラウザ上で動かして確かめられるのが次のサンプルです。
fetchUser.pending・fetchUser.fulfilledといったactionが自動生成されるため、
ローディング状態の管理を自前で書く必要がありません。extraReducersでそれぞれの
タイミングに対応するstate更新を書くだけで、非同期処理の一連の流れを表現できます。
試しにextraReducersへ.addCase(fetchUser.rejected, (state) => { state.status = 'failed' })
を追加し、fetchUserの中でPromise.reject(new Error('取得失敗'))を返すように書き換えて
みてください。pending・fulfilled・rejectedという非同期処理の3状態を、Redux Toolkitが
createAsyncThunkだけで自動的にactionへ変換してくれることが分かります。
まとめ
Reduxは「ボイラープレートが多くて面倒」という評判が先行しがちですが、
Redux Toolkitを使えばcreateSliceによるaction・reducerの一元管理、
Immerによる直感的なstate更新、createAsyncThunkによる非同期処理の簡素化など、
かつての面倒な部分の多くが解消されています。単一のstoreで一方向のデータフローを保証する
Reduxの設計思想は、大規模なアプリほど威力を発揮します。まずは@reduxjs/toolkitから
触ってみて、そのシンプルさを体感してみてください。