Redux Essentials 学習メモ
公式の Tutorial にはトップダウン指向な Redux Essentials とボトムアップ指向な Redux Fundamentals の2つがあり、この順に進めることが推奨されている
Redux Overview and Concepts
ツールについての説明などが書いてあり、その後にコンセプトが続く
Terms and Concepts
State Management
- state: source of truth
-
view:
state
に依存して宣言的に記述される -
actions: ユーザの入力によって発生するイベントで、
state
の変更をトリガーする
Terminology
-
Actions: アプリケーションの中で何が起こったのかを表現するイベントオブジェクトと見做せる
- 実態は、必須の
type
フィールドを持つ普通の JavaScript オブジェクト
- 実態は、必須の
-
Action Creators:
action
を返す関数 -
Reducers:
state
,action
を入力として次のstate
を出力する関数-
action
(event) のtype
に基づいてイベントを処理するイベントリスナーと見做せる - 決定的(非ランダム)であること、
state
が immutable であること、非同期処理や副作用を含まないことが必要
-
-
Store: アプリケーションの現在の状態が格納される変数
-
reducer
から生成することができ、getState()
メソッドでstate
を返す
-
-
Dispatch:
store
のメソッドで、action
を引数にとってstate
を変化させる- これが
state
を変化させる唯一の手段である -
dispatch
メソッドを実行することは、イベントをトリガーすることと見做せる
- これが
-
Selectors
store.state
から特定の値を参照するための関数- 頻繁に参照するプロパティがある場合便利(?)(伏線)
Redux App Structure
シンプルなカウンターアプリを具体例としている
"Application Contents" のセクション
Redux Slices
A "slice" is a collection of Redux reducer logic and actions for a single feature in your app, typically defined together in a single file. The name comes from splitting up the root Redux state object into multiple "slices" of state.
"slice" とは、(ほとんどの場合)同一のファイル内で定義される、アプリケーションの中のある一つの機能のための reducer のロジックと複数の actions の集合のこと
例
reduxjs/redux-essentials-counter-example リポジトリを見る
src/features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
export const slice = createSlice({
name: 'counter',
initialState: {
value: 0,
},
reducers: {
increment: state => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.value += 1;
},
decrement: state => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
},
},
});
...
export default slice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
export default configureStore({
reducer: {
counter: counterReducer,
},
});
上記の例では counter
が一つの "slice"
import { configureStore } from '@reduxjs/toolkit'
import usersReducer from '../features/users/usersSlice'
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
がそれぞれ別々の "slice" である。
usersReducer
は、"slice" の一つである state.users
の変更を担当するので、"slice reducer" 関数と呼ばれる(他2つについても同様)
補足
API リファレンスの createSlice より引用。
上記の例では configureStore
, 以下では createStore
と使用している関数は違うが、いずれも slice オブジェクトから store を構築している:
import { createSlice, createAction, PayloadAction } from '@reduxjs/toolkit'
import { createStore, combineReducers } from 'redux'
const incrementBy = createAction<number>('incrementBy')
const decrementBy = createAction<number>('decrementBy')
const counter = createSlice({
name: 'counter',
initialState: 0 as number,
reducers: {
increment: (state) => state + 1,
decrement: (state) => state - 1,
multiply: {
reducer: (state, action: PayloadAction<number>) => state * action.payload,
prepare: (value?: number) => ({ payload: value || 2 }), // fallback if the payload is a falsy value
},
},
// "builder callback API", recommended for TypeScript users
extraReducers: (builder) => {
builder.addCase(incrementBy, (state, action) => {
return state + action.payload
})
builder.addCase(decrementBy, (state, action) => {
return state - action.payload
})
},
})
const user = createSlice({
name: 'user',
initialState: { name: '', age: 20 },
reducers: {
setUserName: (state, action) => {
state.name = action.payload // mutate the state all you want with immer
},
},
// "map object API"
extraReducers: {
// @ts-expect-error in TypeScript, this would need to be [counter.actions.increment.type]
[counter.actions.increment]: (
state,
action /* action will be inferred as "any", as the map notation does not contain type information */
) => {
state.age += 1
},
},
})
const reducer = combineReducers({
counter: counter.reducer,
user: user.reducer,
})
const store = createStore(reducer)
store.dispatch(counter.actions.increment())
// -> { counter: 1, user: {name : '', age: 21} }
store.dispatch(counter.actions.increment())
// -> { counter: 2, user: {name: '', age: 22} }
store.dispatch(counter.actions.multiply(3))
// -> { counter: 6, user: {name: '', age: 22} }
store.dispatch(counter.actions.multiply())
// -> { counter: 12, user: {name: '', age: 22} }
console.log(`${counter.actions.decrement}`)
// -> "counter/decrement"
store.dispatch(user.actions.setUserName('eric'))
// -> { counter: 12, user: { name: 'eric', age: 22} }
Creating Slice Reducers and Actions
"slice" の名前 (ここでは "counter"
) と、その中の一つの reducer function (ここでは "increment"
) から、action {type: "counter/increment"}
が生成される。
さらに、それらの action に対する処理を担当する slice reducer function も生成される。
import { createSlice } from '@reduxjs/toolkit'
export const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0
},
reducers: {
increment: state => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
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
Rules of Reducers
reducers (reducer functions) はユーザ定義だが、以下のルールに従う必要がある:
-
state
,action
を入力として次のstate
を計算し、それ以外のことをしない - 入力の
state
は書き換えず、複製したものに変更を加えて返す - 非同期処理や副作用を含むロジックを含まない
Reducers and Immutable Updates
結論から言うと、createSlice
関数もしくは createReducer
関数の内部で reducer function を定義する場合、簡潔な表現で immutable update を実現することができる。
なぜなら、createSlice
(と createReducer
) は Immer という
ライブラリを使って実装されているからである。
つまり、本来は mutable と解釈される書き方による immutable update の糖衣構文が用意されていると捉えることができる。
createSlice
function
Details of API ドキュメント を参照する。
これによると引数は
function createSlice({
// A name, used in action types
name: string,
// The initial state for the reducer
initialState: any,
// An object of "case reducers". Key names will be used to generate actions.
reducers: Object<string, ReducerFunction | ReducerAndPrepareObject>
// A "builder callback" function used to add more reducers, or
// an additional object of "case reducers", where the keys should be other
// action types
extraReducers?:
| Object<string, ReducerFunction>
| ((builder: ActionReducerMapBuilder<State>) => void)
})
というオブジェクトで、戻り値は
{
name : string,
reducer : ReducerFunction,
actions : Record<string, ActionCreator>,
caseReducers: Record<string, CaseReducer>.
getInitialState: () => State
}
のようなオブジェクトである。
Async Logic with Thunks
Detailed Explanation を読むのがわかりやすいと思う
ミドルウェアを追加することで、通常の action
(JavaScript オブジェクト) と、非同期な action
(function) の両方を同時に扱うことができるようになる
The React Counter Component
React での使い方について
React がビルトインの hook useState
useEffect
を持っているように、Redux などのサードパーティライブラリはそれぞれ固有の hook を持つ。
- Reading Data with
useSelector
- 前述の "selector" 関数を使う(伏線回収!)
-
const count = useSelector(selectCount)
などと書ける - "selector" としては、その場で無名関数を定義するでも別に構わない
- action が dispatch されて store.state が更新される度に、"selector" が再実行され、再レンダリングされる
- Dispatching Actions with
useDispatch
- action (ここでは
increment
)を dispatch するために、dispatch(increment))
と書くことができる - そのためには
const dispatch = useDispatch()
でdispatch
をインポートする
- action (ここでは
Component State and Forms
あるデータを Redux store に含めるべきかどうかの判断基準
- そのデータを、アプリの他の場所から使うか?
- そのデータをもとに、別のデータを生成するか?
- そのデータが、複数のコンポーネントを駆動するのに使われるか?
- そのデータは、ある特定のタイミングで復元される必要があるか?
- そのデータをキャッシュしておきたいか?
- そのデータを、UIコンポーネントをリロードした後でも保存しておきたいか?
Discussion