🌊

Redux Essentials 学習メモ

2021/11/24に公開

公式の 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;

src/app/store.js

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 の糖衣構文が用意されていると捉えることができる。

Details of createSlice function

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 をインポートする

Component State and Forms

あるデータを Redux store に含めるべきかどうかの判断基準

  • そのデータを、アプリの他の場所から使うか?
  • そのデータをもとに、別のデータを生成するか?
  • そのデータが、複数のコンポーネントを駆動するのに使われるか?
  • そのデータは、ある特定のタイミングで復元される必要があるか?
  • そのデータをキャッシュしておきたいか?
  • そのデータを、UIコンポーネントをリロードした後でも保存しておきたいか?

Discussion