以下のコマンドで生成されるフォルダ、ファイルを主にみていきます。
$ npx create-react-app redux-practice --template redux
Application Contents
- /src
- index.js
- App.js
- /app
- store.js // Reduxのストアインスタンスを作成
- /features
- counter.js // カウンター機能のUIを表示するReactコンポーネント
- counterSlice.js // カウンター機能のためのReduxロジック
Creating the Redux Store
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
Reduxのstoreは、Redux ToolkitのconfigureStore
関数を使って作成します。
configureStore
では、reducerの引数を渡す必要があります。
そして、configureStore
を呼び出す際に、すべての異なるreducerをオブジェクトとして渡すことができます。
このオブジェクトのキー名が、最終的なstateの値のキーを定義することになります。
features/counter/counterSlice.js
というファイルがあり、カウンターのロジックのためのreducer関数をexportしています。
このcounterReducer
関数をここでimportして、ストアを作成するときに含めることができます。
{ counter: counterReducer }
のようなオブジェクトを渡すと、Reduxのstateオブジェクトにstate.counterセクションがあり、actionがdispatchされたときにstate.counterセクションを更新するかどうか、どのように更新するかをcounterReducer関数に任せることができます。
Redux Slices
Sliceとは、アプリ内の1つの機能に対するReduxのreducerロジックとactionの集合体で、通常は1つのファイルにまとめて定義されています。
例えばブログアプリでは、storeの設定は次のようになります。
import { configureStore } from "@reduxjs/toolkit"
import userReducer from "../features/users/userSlice"
import postsReducer from "../features/posts/postsSlice"
import commentsReducer from "../features/comments/commentsSlice"
export default configureStore({
reducer: {
users: usersReducer,
posts: postsReducer,
comments: commentsReducer,
}
})
この例では、state.users
state.posts
state.comments
がそれぞれのReduxのstateの個別のSliceになっています。
usersReducerは、state.usersのスライスを更新する役割を担っているので、sliceReducer
関数と呼びます。
詳しい説明 Reducerとstate構造
Redux Storeは、作成時に単一のrootReducer
関数が渡される必要があります。
では、様々なsliceReducer関数がある場合、代わりに単一のrootReducer関数を取得するには、どうすればよいのでしょうか?
すべてのsliceReducerを手動で呼び出してみると、次のようになります。
function rootReducer(state={}, action) {
return {
users: usersReducer(state.users, action),
posts: postsReducer(state.posts, action),
comments: commentsReducer(state.comments, action)
}
}
これは各sliceReducerを個別に呼び出し、Reduxの状態の特定のsliceを渡し、最終的にReduxのstateオブジェクトに各戻り値を含めるものです。
Reduxには、これを自動的に行う、combineReducers
という関数があります。
この関数は、sliceReducerを含むオブジェクトを引数として受け取り、actionがdispatchされるたびに各sliceReducerを呼び出す関数を返します。
各sliceReducerからの結果は、最終的に1つのオブジェクトにまとめられます。
前述の値と同じことをcombineReducers
を使って行うことができます。
const rootReducer = combineReducers({
users: usersReducer,
posts: postsReducer,
comments: commentsReducer,
})
sliceReducerのオブジェクトをconfigureStoreに渡すと、configureStoreはそれらをcombineReducers
に渡し、rootReducerを生成します。
先ほど見たように、reducerの引数として、reducer関数を直接渡すこともできます。
const store = configureStore({
reducer: rootReducer
})
Creating Slice Reducers and Actions
import { createSlice } from "@reduxjs/toolkit"
export const counterSlice = createSlice({
name: coutner,
initialState: {
value: 0
},
reducers: {
increment: (state) => {
state.value += 1
},
decrement: () => {
state.value -= 1
},
incrementByAmount: (state, action) => {
state.value += action.payload
}
}
})
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
actionは、typeフィールドを持つオブジェクトであり、typeフィールドは常に文字列であり、actionオブジェクトを生成して返すActionCreator関数が一般的であることがわかっています。
では、これらのactionオブジェクト、type文字列、ActionCreatorはどこで定義されているのでしょうか?
Redux Toolkitには、createSliceという関数があり、action type文字列、ActionCreator関数、actionオブジェクトの生成作業を行ってくれます。
このsliceの名前を定義し、いくつかのreducer関数を組み込んだオブジェクトを書くだけで、対応するactionコードが自動的に生成されます。
nameオプションの文字列は、各action typeの最初の部分として使用され、各reducer関数のキー名が2番目の部分として使用されます。
つまり、counter
という名前 + increment
というreducer関数で、{type: "counter/increment"}というaction typeが生成されます。
createSliceでは、nameフィールドに加えて、reducerの初期状態の値を渡す必要があり、初めて呼ばれたときに状態があるようにします。
この場合、0から始める値のフィールドをもつオブジェクトをinitialStateとして提供しています。
ここでは、3つのreducer関数があることがわかります。これは、それぞれのボタンをクリックすることで、dispatchされた3つの異なるaction typeに対応しています。
うちの1つを呼び出して、何が返ってくるか確認してみましょう。
console.log(counterSlice.actions.increment())
// {type: "counter/increment"}
また、これらすべてのaction typeに対応する方法を知っているsliceReducer関数も生成されます。
const newState = counterSlice.reducer(
{value: 10},
counterSlice.actions.increment()
)
console.log(newState)
// {value: 11}
Reducerのルール
さきほど、Reducerは常にいくつかの特別なルールに従わなければならないと言いました。
- stateとactionの引数に基づいて、新しいstateの値を計算するだけである
- 既存のstateを更新してはいけない。代わりに既存のstateをコピーし、コピーされた値に変更を加えることでイミュータブルな更新を行わなければならない
- 非同期のロジックやその他の"副作用"をおこなってはいけない
なぜこのようなルールが必要なのでしょうか?
- Reduxの目的の1つは、コードを予測可能にすること。関数の出力が入力された引数からのみ計算される場合、そのコードがどのように動作するかを理解し、テストすることが容易になります。
- 一方で、関数が自分以外の変数に依存したり、ランダムに動作したりすると実行したときに何が起こるかわかりません。
- 関数が引数を含む他の値を変更した場合、アプリケーションの動作が予期せず変更される可能性があります。これは「stateを更新したのに、UIが本来のタイミングで更新されない!」といった、よくあるバグの原因になります。
- Redux DevToolsの機能の中には、reducerがこれらのルールに正しく従うことに依存するものがあります。
Reducerとイミュータブルな更新
Reduxでは、Reducerは元/現在のstateの値を変更することは絶対に許されません。
- 最新の値を表示するためにUIが正しく更新されないなどのバグが発生する
- 状態が更新された理由や方法を理解するのが難しくなる
- テストを書くのが難しくなる
では、どうやって更新された状態を返すのか?
return {
...state,
value: 123
}
JavaScriptの配列/オブジェクトのspread operatorsや、元の値のコピーを返す他の関数を使うことで、不変の更新を手書きでかけることがわかります。
しかし、更新ロジックを書くのは大変ですし、Reduxユーザが誤ってreducerで状態を変異させてしまうのは、よくあるミスです。
そのため、Redux ToolkitのcreateSlice
関数を使えば、不変的な更新を簡単に書くことができます。
createSliceは、内部でImmer
というライブラリを使用しています。ImmerはProxyと呼ばれる特殊なJSツールを使って、提供されたデータをラップし、そのラップされたデータを変異させるコードを書くことができます。
しかし、Immerはあなたが行った変更を全て追跡し、その変更リストを使って、あたかも不変の更新ロジックをすべて手動で書いたかのように完全に不変に更新された値を返します。
つまり、このように書くのではなく
function handwrittenReducer(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue
}
}
}
}
}
以下のようなコードを書くことができます。
function reducerWithImmer(state, action) {
state.first.second[action.someId].fourth = action.someValue
}
ずいぶんと読みやすくなったと思います。しかし、注意することがあります。
Redux ToolkitのcreateSliceとcreateReducerは内部でImmerを使用しているため、ミュータブルなロジックはRedux ToolkitのcreateSliceとcreateReducerにしか書くことができません。Immerを使用していないReducerで変異するロジックを書くと、状態が変異してしまい、バグの原因となります。
では、もう一度counterSlice.jsを確認してみましょう。
import { createSlice } from "@reduxjs/toolkit"
export const counterSlice = createSlice({
name: coutner,
initialState: {
value: 0
},
reducers: {
increment: (state) => {
state.value += 1
},
decrement: (state) => {
state.value -= 1
},
incrementByAmount: (state, action) => {
state.value += action.payload
}
}
})
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
increment Reducerは、state.valueに常に1を加えることがわかります。
ドラフトのstateオブジェクトに変更を加えたことをImmerは知っているので、ここで実際に何かを返す必要はありません。
同じようにdecrement Reducerは1を引きます。
どちらのReducerでもactionオブジェクトを実際にコードで確認する必要はありません。いずれにしてもactionオブジェクトは渡されますが、必要がないので、reducerのパラメーターとしてactionを宣言することは省略できます。
一方、incrementByAmount Reducerは、カウンタの値にどれだけ追加するかを知る必要があります。
そこで、reducerにstateとactionの両方の引数を持たせることを宣言します。
このケースでは、テキストボックスに入力した金額が、action.payloadフィールドに入力されることがわかっているので、それをstate.valueに追加しています。