はじめに
この記事は自分がReduxを理解するために、公式サイトの内容を翻訳しながらまとめていく記事となります。
Redux Terms and Concepts
State Manegement
state: アプリを動かす源
action: ユーザの入力に基づいてアプリ内で発生するイベントで、stateの更新のトリガーになります。
stateは特定の時点でのアプリの状態を表します。その状態に基づいてUIがレンダリングされます。
そしてユーザがボタンをクリックするなど、何かが発生すると、発生した内容に基づいて状態が更新され、その新しい状態に基づいてUIが再レンダリングされます。
しかし、同じstateを共有して使用する必要のある複数のコンポーネントがある場合、シンプルさが崩れてしまう。
この問題を解決する1つの方法は、「コンポーネントから共有状態を抽出し、それをコンポーネントツリーの外部にある場所におくこと」です。
これにより、コンポーネントツリーは大きな「ビュー」となり、どのコンポーネントのどのツリーにいたとしてもstateにアクセスしたり、actionを起こしたりすることができます。
Immutability
JavaScriptのオブジェクトや配列はデフォルトではすべて変更可能です。
const obj = { a: 1, b: 2 }
obj.b = 3
const arr = ['a', 'b']
arr.push('c')
arr[1] = 'd'
これをオブジェクトや配列のミューティングと呼びます。
メモリ内のオブジェクトや配列の参照は同じですが、オブジェクト内のコンテンツが変更されています。
値を普遍的に更新するためには、コードは既存のオブジェクトや配列のコピーを作成し、そのコピーを変更する必要があります。
const obj = {
a: {
c: 3
},
b: 2
}
const obj2 = {
...obj,
a: {
...obj.a,
c: 42
}
}
const arr = ['a', 'b']
const arr2 = arr.concat('c')
const arr3 = arr.slice()
arr3.push('c')
Reduxではすべての状態の更新が不変的に行われることを期待しています。
Terminology
Reduxの重要な用語について
Action
actionとは、typeフィールドをもつ、プレーンなJSオブジェクト。actionはアプリケーションで起こったことを記述するイベントと考えることができます。
typeフィールドには、"todos/todoAdded"のように、このアクションに説明的な名前を与える文字列を指定します。
通常は"domain/eventName"のように記述します。
actionオブジェクトは、何が起こったかについての追加情報をもつ他のフィールドをもつことができます。
慣習的に、その情報はpayloadと呼ばれるフィールドに入れます。
const addTodoAction = {
type: "todos/todoAdded",
payload: "Buy milk"
}
Action Creators
Action Creatorsとは、actionオブジェクトを作成して返す関数です。
一般的には、actionオブジェクトを毎回書く必要がないように、これを使用します。
const addTodo = () => {
return {
type: 'todos/todoAdded',
payload: text
}
}
Reducers
Reducersとは、現在のstateとactionを受け取り、必要に応じてstateをどのように更新するかを決定し、新しいstateを返す関数です。
受け取ったaction.typeによってイベントを処理するイベントリスナーと考えることができます。
Reducerは常にいくつかのルールに従わなければなりません。
- Reducerは、stateとactionの引数に基づいて新しいstate値を計算するだけ
- 既存の状態を変更することはできません。代わりに既存のstateをコピーし、コピーされた値に変更を加えることで、イミュータブル(不変的)な更新を行わなければなりません。
- 非同期のロジックを実行したり、ランダムな値を計算したり、その他の"副作用"を起こしてはいけません。
Reducer関数内のロジックは通常、同じ一連のステップに従います。
- Reducerがこのactionを気にしているかどうかをチェックする
- もしそうであれば、状態のコピーを作成し、新しい値でコピーを更新し、それを返す
- そうでなければ既存の状態を更新せずに返す
const initialState = { value: 0 }
const counterReducer = (state = initialState, action) => {
if (action.type === 'counter/increment') {
return {
...state,
value: state.value + 1
}
}
return state
}
store
現在のReduxアプリケーションの状態は、storeと呼ばれるオブジェクトに格納されています。
storeはreducerを渡すことで作成され、現在の状態の値を変えずgetStateというメソッドを持っています。
import { configureStore } from '@reduxjs/toolkit'
const store = configureStore({ reducer: counterReducer })
console.log(store.getState())
// {value: 0}
dispatch
storeにはdispatchというメソッドがあります。状態を更新する唯一の方法は、store.dispatchを呼び出し、actionオブジェクトを渡すことです。
storeは、reducer関数を実行して、新しいstateを内部に保存し、getStateを呼び出して更新された値を取り出すことができます。
store.dispatch({ type: 'counter/increment' })
console.log(store.getState())
// {value: 1}
actionのdispatchは、アプリケーションでの「イベントのトリガー」と考えることができます。
何かが起こって、それをstoreに知らせたいのです。
Reducerはイベントリスナーのような役割を果たし、関心のあるactionを聞くと、それに応じ状態を更新します。
一般的にはAction Creatorを呼び出して、正しいactionをdispatchします。
const increment = () => {
return {
type: 'counter/increment'
}
}
store.dispatch(increment())
console.log(store.getState())
// {value: 2}
Selectors
selectorsは、store stateの値から、特定の情報を抽出する方法を知っている関数です。アプリケーションの規模が大きくなると、アプリケーションのさまざまな部分で同じデータを読みよる必要があるため、ロジックの繰り返しを避けることができます。
const selectCounterValue = (state) => state.value
const currentValue = selectCounterValue(store.getState())
console.log(currentValut)
// 2
Data Flow
初期状態
Reduxのstoreは、rootReducer関数を使用して作成されます。
storeはrootReducer関数を一度呼び出し、その戻り値を初期状態として保存します。
UIに最初にレンダリングされるときに、UIコンポーネントはRedux Storeの現在の状態にアクセスし、そのデータを使って何をレンダリングするかを決定します。
また、将来のstoreの更新をsubscribeして、状態が変化したかどうかを知ることができます。
更新
ユーザがボタンをクリックするなど、アプリ内で何かが起こります。
アプリのコードはdispatch({type: "counter/increment"})
のようにactionをRedux Storeにdispatchします。
storeは以前の状態と現在のactionでreducer関数を再度実行し、その戻り値を新しい状態として保存します。
storeは、subscribeしているUIのすべての部分にstoreが更新されたことを通知します。
storeからのデータを必要とする各UIコンポーネントは、必要な状態の部分が変更されたかどうかを確認します。
データが更新されたことを確認した各コンポーネントは、新しいデータで再レンダリングを行い、画面に表示される内容を更新します。
Thunkで非同期処理を書く
JavaScriptを用いてアプリケーションを作るとき、非同期処理のロジックを書くことが必要になってくるため、Reduxには非同期処理を置く場所が必要です。
Thunkは、非同期処理を含むことができる特定の種類のRedux関数です。Thunkは2つの関数を使って記述します。
- 内部のThunk関数は、引数としてdispatchとgetStateを受け取ります。
- 外部のcreator関数は、Thunk関数を生成して返します。
counterSliceからexportされた次の関数は、Thunk Action Creatorの例です。
export const incrementAsync = amount => dispatch => {
setTimeout(() => {
dispatch(incrementByAmount(amount))
}, 1000)
}
これは一般的なReduxのAction Creatorと同じように使うことができます。
store.dispatch(incrementAsync(5))
しかし、thunkを使用するには、Redux storeの作成時に、redux-thunkミドルウェアを追加する必要があります。
幸いなことに、Redux ToolkitのconfigureStore関数が自動的に設定してくれているため、ここではthunkを使用することができます。
サーバからデータを取得するために、AJAXコールを行う必要がある場合、そのコールをthunkに入れることができます。
// 外側はthunk creator機能
const fetchUserById = userId => {
// 内部のthunk関数
return async (dispatch, getState) => {
try {
// thunkで非同期処理の呼び出しを行う
const user = await userAPI.fetchById(userId)
// レスポンスが返ってきたらactionをdispatchする
dispatch(userLoaded(user))
} catch(err) {
console.log(err)
}
}
}
詳しい説明 thunkと非同期処理
reducerに非同期処理を入れてはいけないことは説明しました。しかし、その非同期処理はどこかに置かなければなりません。
もしReduxStoreにアクセスできるのであれば非同期処理コードをかいて、終わったらstore.dispatch()を呼び出すことができます。
const store = configureStore({ reducer: counterReducer })
setTimeout(() => {
store.dispatch(increment())
}, 300)
しかし、実際のReduxアプリでは、storeを他のファイル、特にReactコンポーネントにimportすることはできません。
さらに、最終的に何らかのstoreで使用されることがわかっている非同期処理を書かなければならないこともよくありますが、どのstoreかはわかりません。
Reduxのstoreは、ミドルウェアを使って拡張することができます。
ミドルウェアはアドオンやプラグインのようなもので、機能を追加することができます。ミドルウェアを使用するもっとも一般的な理由は、非同期処理のロジックを持ちながら、同時にstoreと対話できるコードをかけるようにすることです。
ミドルウェアは、dispatchを呼び出したり、関数やPromiseのようなactionオブジェクトではない値を渡せるようにstoreを修正することもできます。
Redux Thunkミドルウェアは、dispatchに関数を渡せるようにstoreを修正します。
const thunkMiddleware = ({dispatch, getState}) => next => action => {
if(typeof action === "function") {
return action(dispatch, getState)
}
return next(action)
}
dispatchに渡されたactionが単なるactionオブジェクトではなく、実際に関数であるかどうかを確認します。もし関数であれば、その関数を呼び出し、結果を返します。
そうでなければactionオブジェクトでなければならないので、actionをstoreに返します。
これらにより、dispatchとgetStateにアクセスしながら、好きな同期・非同期処理を書くことができます。
React Counter Component
まずは、Counter.jsというコンポーネントファイルを見てみます。
import React, { useState } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import {
decrement,
increment,
incrementByAmount,
incrementAsync,
selectCount
} from './counterSlice'
import styles from './Counter.module.css'
export function Counter() {
const count = useSelector(selectCount)
const dispatch = useDispatch()
const [incrementAmount, setIncrementAmount] = useState('2')
return (
<div>
<div className={styles.row}>
<button
className={styles.button}
aria-label="Increment value"
onClick={() => dispatch(increment())}
>
+
</button>
<span className={styles.value}>{count}</span>
<button
className={styles.button}
aria-label="Decrement value"
onClick={() => dispatch(decrement())}
>
-
</button>
</div>
{/* omit additional rendering output here */}
</div>
)
}
このコンポーネントでは、useStateを使って現在のカウンター値をstateとして保存しているようには見えません。countという変数がありますが、それはuseStateから来たものではありません。
React-Reduxライブラリは、ReactコンポーネントがReduxStoreと対話できるようにするカスタムフックを持っています。
useSlectorでデータを読みこむ
まずuseSelectorフックは、コンポーネントがReduxStoreのstateから必要なデータを抽出するためのものです。
先ほど、stateを引数にとり、stateの値の一部を返す、slector関数を書くことができると説明しました。
counterSlice.jsの下には、このselector関数があります。
// The function below is called a selector and allows us to select a value from
// the state. Selectors can also be defined inline where they're used instead of
// in the slice file. For example: `useSelector((state) => state.counter.value)`
export const selectCount = state => state.counter.value
現在のcounterの値を次のようにして取り出すことができます。
const count = selectorCount(store.getState())
console.log(count)
// 0
コンポーネントファイルにReduxStoreをimportすることはできないので、コンポーネントはReduxと直接対話することはできません。
しかし、useSelectorは裏でReduxStoreとのやりとりを代行してくれます。
selector関数を渡すと、someSelector(store.getState())を呼び出し、その結果を返してくれます。
つまり、次のようにすれば、現在のStoreCounterの値を得ることができます。
const count = useSelector(selectCount)
actionがdispatchされ、ReduxStoreが更新されると、useSelectorはselector関数を再度実行します。
selectorが前回と異なる値を返した場合、useSelectorはコンポーネントが新しい値で再レンダリングされるようにします。
useDispatchによるactionのdispatch
Reactコンポーネントは直接ReduxStoreにアクセスすることができないため、dispatchメソッドだけにアクセスする方法が必要になります。
useDispatchはこれを行い、ReduxStoreの実際のdispatchメソッドを提供します。
const dispatch = useDispatch()
そこから、ユーザがボタンをクリックするなど、何かをしたときに、actionをdispatchすることができます。
<button
className={styles.button}
aria-label="Increment value"
onClick={() => dispatch(increment())}
>
+
</button>
コンポーネントの状態とForm
アプリ全体で必要とされるグローバルなstateはReduxStoreにいれるべきですが、1つの場所でしか必要とされないstateは、コンポーネントのstateに入れておくべきです。
この例では、ユーザがcounterに追加する次の数字を入力するための入力テキストボックスがあります。
const [incrementAmount, setIncrementAmount] = useState('2')
// later
return (
<div className={styles.row}>
<input
className={styles.textbox}
aria-label="Set increment amount"
value={incrementAmount}
onChange={e => setIncrementAmount(e.target.value)}
/>
<button
className={styles.button}
onClick={() => dispatch(incrementByAmount(Number(incrementAmount) || 0))}
>
Add Amount
</button>
<button
className={styles.asyncButton}
onClick={() => dispatch(incrementAsync(Number(incrementAmount) || 0))}
>
Add Async
</button>
</div>
)
inputのonChangeハンドラでactionをdispatchして、ReduxのStoreに現在の文字列を保持することもできます。しかし、それでは何のメリットもありません。
テキスト文字列が使われているのは、このCounterコンポーネントだけです。
そのため、この値はCounterコンポーネントのuseStateに保持することは理にかなっています。
どうようにiSsDropdownOpenというboolean値のフラグがあったとしても、アプリ内の他のコンポーネントはこれを気にしないため、Counterコンポーネント内で値を保持しておくのが望ましいです
React + Reduxアプリでは、グローバルなstateはReduxStoreに入れ、ローカルなstateはReactコンポーネントに保持しましょう
どこに何をおくべきか分からなくなった場合、どのような種類のデータをReduxにおくべきかを判断するためのリストを書いておきます。
- アプリケーションのほかの部分がこのデータを気にしているか?
- このオリジナルのデータも基づいてさらに派生したデータをつくれるようにする必要があるか?
- 同じデータが複数のコンポーネントを動かすために使われているか?
- この状態をある時点に戻すことに価値があるか?
- データをキャッシュしたいか?
- UIコンポーネントをホットリロードしている間、このデータの一貫性を維持したいか(スワップされると内部状態が失われる可能性がある)
フォームの値は、ほとんどReduxで管理するものではありません。代わりに編集中のデータをコンポーネントに保存し、ユーザが編集を終えたときにReduxActionをdispatchしてstoreを更新します。
Providing the Store
コンポーネントがuseSelectorやuseDispatchを使用して、ReduxStoreと対話できることを確認しました。
しかし、storeをimportしていないので、次のようにする必要があります。
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import store from './app/store'
import { Provider } from 'react-redux'
import * as serviceWorker from './serviceWorker'
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
というコンポーネントを使って、ReduxのStoreを裏で渡してアクセスできるようにする必要があります。
storeはすでにapp/store.jsで作成しているので、ここでインポートします。
そして、全体にコンポーネントを配置してのようにしてstoreを渡します。
これでuseSelectorやuseDispatchを呼び出したReactコンポーネントは、に渡したReduxStoreと対話することができるようになりました。
まとめ
- ReduxToolkitの
configureStore
を使用して、ReduxStoreを作成することができます - configureStoreは名前付きの引数として
reducer
関数を受け取ります - configureStoreはデフォルト設定でstoreを自動的にセットアップしてくれます
- Reduxのロジックは
Slice
と呼ばれるファイルに整理されます - SliceにはReduxのStateの特定の機能/セクションに関連する
Reducer
とAction
が含まれます - ReduxToolkitの
createSlice
は、提供するここのReducerに応じて、ActionCreator
とActionType
を生成します - ReduxのReducerは特定のルールに従わなければなりません
- stateとactionの引数に基づいて新しいstateのみを計算する
- 既存のstateをコピーして不変的な更新を行うこと
- 非同期処理やその他の"副作用"を含むことはできない
- ReduxToolkitのcreateSliceは、Immerを使用して不変的な更新を可能にしています
- 非同期処理は通常、
Thunk
と呼ばれる特別な関数に記述されます- Thunkは引数として
dispatch
とgetState
を受け取る - ReduxToolkitはデフォルトで
redux-thunk
ミドルウェアを有効にする
- Thunkは引数として
- React-Reduxアプリでは、ReactコンポーネントとReduxStoreを連動させることができます
- アプリを
<Provider store={store}>
でラップすると、すべてのコンポーネントがstoreと連動することができます - グローバルなstateはReduxStoreに、ローカルなstateはReactコンポーネントに残すべきです
- アプリを