tanish-kr's learning log

Learning output log

JavaScript React

Redux

Reduxの仕組み

Actions

Actionは個々のイベントに対する情報のことです。Reduxでは状態変化は全てActionを介して行います。Actionは固有の名前を持ち、状態変化について必要な情報を持つ必要があります。

Reducers

Actionを使って状態変化を生じさせるには、Reducerと呼ばれる関数にActionを送信する必要があります。また、このようにReducerにActionを送信する動作をDispatchと呼びます。

Store

Storeはアプリケーションの状態を保持するオブジェクトのことです。ReactやVue.jsのようなフレームワークからStoreを参照することによってUIにStoreを反映させることができます。

Fluxアーキテクチャ

Flux パターン

Fluxはデータの流れを一方向に限定することで状態遷移を単純にし、データ状態の複雑さを軽減させようとする考え方である。

データは以下の流れで変更されていく

  1. 何らかのActionがあり、Dispatcherがそのすべてを受ける
  2. DispatcherStoreActionを伝搬
  3. StoreDispatcherから受けたActionによってデータの状態を変更
  4. Storeのデータ状態がViewに渡される
  5. ViewStoreから受けたデータを表示
  6. Viewで何らかのActionが発生 -> 1に戻る

メリット

すべてのデータのオペレーションがDispatcherに集約されており、ActionによるStoreの変更は予め決められているため、Actionの発行後のアプリケーションの状態が予測可能になる。

Reduxの原則

  • Single source of truth (信頼できる唯一の情報源)
  • State is read-only (状態は読み取り専用)
  • Changes are made with pure functions (変更は純粋関数にて行われる)

Viewから発行されたActionはDispatcherで割り振られてReducerに渡される。ReducerはそのActionと現在のStateを受け取って新しいStateを返す。

基本的な使い方

Install

$ nmp install react-redux

or

$ yarn add react-redux

Provider

React Redux は<Provider /> を提供します。これによりアプリの他の部分でReduxストアが利用可能になります。 React Reduxアプリの任意のReactコンポーネントを接続できるため、ほとんどのアプリケーションは、アプリのコンポーネントツリー全体を内部に持つをトップレベルにレンダリングします。

  • index.tsx
import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import store from "./store";

import App from "./App";

const rootElement = document.getElementById("root");
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  rootElement
);

Actions

ReduxのActionとそれを生成するAction Createrと呼ばれる関数群を定義する。

  • actions/counterAction.ts
export enum CounterActionType {
  ADD = "COUNTER/ADD",
  DECREMENT = "COUNTER/DECREMENT",
  INCREMENT = "COUNTER/INCREMENT"
}

export interface CounterAction {
  type: CounterActionType;
  payload?: { amount: number };
}

export const add = (amount: number): CounterAction => ({
  payload: { amount },
  type: CounterActionType.ADD
});

export const increment = (): CounterAction => ({
  type: CounterActionType.INCREMENT
});

export const decrement = (): CounterAction => ({
  type: CounterActionType.DECREMENT
});

Actionのフォーマット

interface Action {
  type: string;
  payload?: Object;
  error?: boolean;
  meta?: Object;
}
  • type: Actionの種類を一意に示す文字列、enum、またはストリングリテラル。必須。
  • payload: 主に正常時はActionの実行に必要なデータ。異常時にはエラー情報を格納する。省略可能。
  • error: trueの場合、エラーのActionであることを意味する。省略可能。
  • meta: その他の追加で必要な情報を格納する。省略可能。

https://qiita.com/kabosu3d/items/e00af944529a5bf89d23

Reducer

以前の状態とアクションを組み合わせて、新しい状態を生み出す役割

  • 初期状態はReducerのデフォルト引数で定義される
  • 状態を変更する際、渡されてきたstateをのもを書き換えずに、新しいものを合成するように記述する
import { Reducer } from "redux";
import { CounterAction, CounterActionType } from "../actions/counterActions";

export interface CounterState {
  count: number;
}

export const initState: CounterState = { count: 0 };

const counterReducer: Reducer<CounterState, CounterAction> = (
  state: CounterState = initState,
  action: CounterAction
): CounterState => {
  switch (action.type) {
    case CounterActionType.ADD:
      return {
        ...state,
        count: state.count + (action.payload?.amount || 0)
      };
    case CounterActionType.INCREMENT:
      return {
        ...state,
        count: state.count + 1
      };
    case CounterActionType.DECREMENT:
      return {
        ...state,
        count: state.count - 1
      };
    default: {
      return state;
    }
  }
};

export

ComponentとStoreの接続(HOCによる書き方)

7.1.0以降はHooksインタフェースが提供されているため、新規の場合、この書き方は忘れていい

React Reduxtはコンポーネントをストアに接続するためのconnect関数を提供しています。以下を行いコンポーネントとStoreのやり取りを担う。

  • 参照したいStore Stateの値をコンポーネントのPropsにマッピングする
  • 発行したいActionを生成するAction Creator関数をPropsにマッピングする
import React, { Component } from "react";
import { Dispatch } from "redux";
import { connect } from "react-redux";
import { increment, decrement, add } from "./actions/counterActions";
import { CounterState } from "./reducers/counterReducer";
import "./styles.css";

interface CounterProps {
  count?: number;
  add?: (amount: number) => void;
  increment?: () => void;
  decrement?: () => void;
}

class App extends Component<CounterProps, {}> {
  render() {
    const {
      count = 0,
      increment = () => {},
      decrement = () => {},
      add = () => {}
    } = this.props;
    return (
      <div>
        Clicked: {count} times.
        <button onClick={increment}>+</button>
        <button onClick={decrement}>-</button>
        <button onClick={() => add(10)}>10</button>
      </div>
    );
  }
}

// Storeを受け取ってPropsを返す
const mapStateToProps = (state: CounterState): CounterProps => ({
  count: state.count
});

// Dispatcheを受け取ってPropsを返す。dispatch()に渡して対応させたいAction Creator関数にリンクさせる
const mapDispatchToProps = (dispatch: Dispatch): CounterProps => ({
  add: (amount) => dispatch(add(amount)),
  increment: () => dispatch(increment()),
  decrement: () => dispatch(decrement())
});

export default connect(mapStateToProps, mapDispatchToProps)(App);

Hooks

7.1.0以降に追加されたAPIで、connect()でコンポーネントをラップすることなく、Reduxストアにサブスクライブしてアクションをディスパッチできます。

useSelector

storeから任意のstateの値を抽出することができる。

const result: any = useSelector(selector: Function, equalityFn?: Function)

selectorは概念的にはconnectのmapStateToProps引数とほぼ同等。関数コンポーネントがrenderされるたびに実行される。useSelector()はReduxストアにサブスクライブし、ActionがDispatchされるたびにselectorを実行する。

1つの関数コンポーネント内で複数回呼び出すことが可能。 useSelector()を呼び出すたびに、Reduxストアへの個別のサブスクリプションが作成されます。 React Reduxv7で使用されるReactupdateバッチ動作のため、同じコンポーネント内の複数のuseSelector()が新しい値を返すようにするディスパッチされたアクションは、1回の再レンダリングのみになります。

import { FC } from "react";
import { useSelector } from "react-redux";

interface CounterState {
  count: number
}

export const CounterComponent: FC = () => {
  const count = useSelector<CounterState, number>((state) => state.count);
  return (
    <div>
      {count}
    </div>
  )
}

useDispatch

actionをdispatchする、dispatcherに渡すための関数を取得する

import { React, FC } from "react";
import { useDispatch } from "react-redux";

export const CounterComponent: FC = () => {
  const dispatch = useDispatch()
  return (
    <div>
      <button onClick={() => dispatch({ type: "INCREMENT" })}>
        Increment
      </button>
    </div>
  )
}

useStore

<Provider>に渡されたstoreへの参照を返す。頻繁に使用するべきではないが、reducerの交換等に役立つ

Redux Toolkit

https://redux-toolkit.js.org/

Reduxの開発をサポートしてくれるTool。

Install

# NPM
$ npm install @reduxjs/toolkit

# Yarn
$ yarn add @reduxjs/toolkit

configuStore

createStoreをラップして、簡略化された構成オプションと適切なデフォルトを提供します。スライスリデューサーを自動的に組み合わせ、提供するReduxミドルウェアを追加し、デフォルトでredux-thunkを含め、Redux DevToolsExtensionの使用を可能にします。

Parameters

reducer

ルートreducerとして使用される単一のreducer関数または、combineReducer()に渡されるslice reducerのオブジェクト

middleware

使用したいRedux middlewareを配列でわたすことで、使用可能になる

devTools

Redux Devtoolsを使用するかのフラグ

preloadedState

createStore関数に渡されるinitial state

enhancers

Redux sotre enhancerの配列を指定する

  • Reduxのdispath()関数をラップした処理が記述されたミドルウェア

Example

  • シンプルな使い方
import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "./reducers";

const store = configureStore({ reducer: rootReducer });

createAction

指定されたアクションタイプ文字列のアクションクリエーター関数を生成します。関数自体にはtoString()が定義されているため、型定数の代わりに使用できます。

function createAction(type, prepareAction?)

Example

  • 往来のactionの書き方
const INCREMENT = "counter/increment";

const increment = (amount: number) => {
  return {
    type: INCREMENT,
    payload: amount
  }
}
  • Redux Toolkitを使用した書き方
import { createAction } from "@reduxjs/toolkit";

const increment = createAction<number | undefined>("counter/increment");

prepare callbackを使用したActionのカスタマイズ

デフォルトでは、生成されたAction Createは単一の引数受け入れがaction.payloadになります。しかしAction Createrの複数のパラメータを受け入れたり、ランダムIDの生成、現在のタイムスタンプの取得など、ペイロード値の作成をカスタマイズするための追加のロジックを記述したい場合があります。これを行うために、createActionは、オプションの2番目の引数である「preparecallback」を受け入れます。これは、ペイロード値を作成するために使用されます。

import { createAction, nanoid } from "@reduxjs/toolkit";

const addTodo = createAction("tods/add", function prepare(text: string) {
  return {
    payload: {
      text,
      id: nanoid(),
      createdAt: new Date().toISOString(),
    }
  }
})

createReducer

これにより、switchステートメントを記述するのではなく、アクションタイプのルックアップテーブルをケースリデューサー関数に提供できます。さらに、Immerライブラリを自動的に使用して、通常の変更コードを使用して、より単純な不変の更新を記述できるようにします。

Example

  • 往来のreducerの書き方
const initialState = { value: 0 };

const counterReducer = (state = initalState, action) {
  switch(action.type) {
    case "increment":
      return { ...state, value: state.value + 1 };
    case "decrement":
      return { ...state, value: state.value - 1 };
    case "add":
      return { ...state, value: state.value + action.payload };
    default:
      return state;
  }
}
  • Redux Toolkitを使用した書き方
import { createAction, createReducer, PayloadAction } from "@reduxjs/toolkit";

const increment = createAction("counter/increment");
const decrement = createAction("counter/decrement");
const add = createAction<number>("counter/add");

const initialState = { value: 0 } as CounterState;

const counterReducer = createReducer(initialState, {
  [increment.type]: (state) => ({
    ...state,
    value: state.value + 1
  }),
  [decrement.type]: (state) => ({
    ...state,
    value: state.value - 1
  }),
  [add.type]: (state, action: PayloadAction<number>) => ({
    ...state,
    value: state.value + action.payload
  })
}
  • Builderを使用した書き方

より厳密な型安全性が必要な場合は、このAPIが推奨されている

import { createAction, createReducer } from "@reduxjs/toolkit";

interface CounterState {
  value: number
}

// Action
const increment = createAction("counter/increment");
const decrement = createAction("counter/decrement");
const add = createAction<number>("counter/add");

const initialState = { value: 0 } as CounterState;

const counterReducer = createReducer(initialState, (builder) => {
  builder
    .addCase(increment, (state, action) => {
      state.value++;
    })
    .addCase(decrement, (state, action) => {
      state.value--;
    })
    .addCase(add, (state, action) => {
      state.value += action.payload
    })
})

createSlice

レデューサー関数のオブジェクト、スライス名、および初期状態値を受け入れ、対応するアクション作成者とアクションタイプを使用してスライスレデューサーを自動的に生成します。

Example

import { createSlice, PayloadAction } from "@reduxjs/toolkit";

interface CounterState {
  value: number
}

const initialState = { value: 0 } as CounterState;

const counterSlice = createSlice({
  name: "counter", // Actionタイプで使用される名前
  initialState, // reducerの初期state
  reducers: { // case reducerのオブジェクト
    increment(state){ // reducerを追加するために使われるbuilder callback関数、または case reducerの追加オブジェクト。(keyは他のオブジェクトにする必要がある)
      state.value++
    },
    decrement(state){
      state.value--
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      state.value += action.payload
    }
  }
})

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;

extraReducers

Reduxの重要な概念の1つは、各スライスレデューサーがその状態のスライスを「所有」し、多くのスライスレデューサーが同じアクションタイプに独立して応答できることです。 extraReducersを使用すると、createSliceは、生成したタイプ以外の他のアクションタイプに応答できます。

extraReducersで指定されたケースリデューサーは「外部」アクションを参照することを目的としているため、slice.actionsで生成されたアクションはありません。

extraReducersを使用するための推奨される方法は、ActionReducerMapBuilderインスタンスを受け取るコールバックを使用することです。

import { createAction, createSLice, Action, AnyAction } from "@reduxjs/toolkit";
const incrementBy = createAction<number>("incrementBy");
const decrement = createAction("decrement");

interface RejectedAction extends Action {
  error: Error
}

function isRejectedAction(action: AnyAction): action is RejectedAction {
  return action.type.endsWith("rejected");
}

createSlice({
  name: "counter",
  intialState: 0,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(incrementBy, (state, action) => {
        // TSを使用していす場合,actionはここで正しく推測されます
      })
      .addCase(decrement, (state, action) => {})
      .addMatcher(
        isRejectedAction,
        (state, action) =>
      )
      .addDefaultCase((state, action) => {})
  }
})

Return Value

{
  name: string,
  reducer: ReducerFunction,
  actions: Record<string, ActionCreator>,
  caseReducers: Record<string, CaseReducer>
}

createAsyncThunk

アクションタイプの文字列とpromiseを返す関数を受け入れ、そのpromiseに基づいて保留中/実行済み/拒否済みのアクションタイプをディスパッチするThunkを生成します

import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { userAPI } from "./userAPI";

const fetchUserById = createAsyncThunk(
  "users/fetchByIdStatus",
  async(userId, thunkAPI) => {
    const response = await userAPI.fetchById(userId);
    return response.data;
  }
)

const usersSlice = createSlice({
  name: "users",
  initialState: { entities: [], loading: "idle" },
  reducers: {

  },
  extraReducers: {
    [fetchUserById.fulfilled]: (state, action) => {
      state.entities.push(action.payload)
    }
  }
})

dispatch(fetchUserById(123))

Parameter

type

非同期リクエストを生成するための、Redux action type定数を生成するための文字列を渡す

payloadCreator

いくつかの非同期ロジックの結果を含むpromiseを返す必要があるコールバック関数。また、同期的に値を返す場合もあります。エラーが発生した場合は、Errorインスタンスを含む拒否されたプロミス、説明的なエラーメッセージなどのプレーンな値、またはthunkAPI.rejectWithValue関数によって返されるRejectWithValue引数を使用した解決済みのPromiseを返す必要があります。

createEntityAdapter

ストア内の正規化されたデータを管理するための再利用可能なレデューサーとセレクターのセットを生成します

import {
  createEntityAdapter,
  createSlice,
  configureStore,
} from '@reduxjs/toolkit'

type Book = { bookId: string; title: string }

const booksAdapter = createEntityAdapter<Book>({
  // Assume IDs are stored in a field other than `book.id`
  selectId: (book) => book.bookId,
  // Keep the "all IDs" array sorted based on book titles
  sortComparer: (a, b) => a.title.localeCompare(b.title),
})

const booksSlice = createSlice({
  name: 'books',
  initialState: booksAdapter.getInitialState(),
  reducers: {
    // Can pass adapter functions directly as case reducers.  Because we're passing this
    // as a value, `createSlice` will auto-generate the `bookAdded` action type / creator
    bookAdded: booksAdapter.addOne,
    booksReceived(state, action) {
      // Or, call them as "mutating" helpers in a case reducer
      booksAdapter.setAll(state, action.payload.books)
    },
  },
})

const store = configureStore({
  reducer: {
    books: booksSlice.reducer,
  },
})

type RootState = ReturnType<typeof store.getState>

console.log(store.getState().books)
// { ids: [], entities: {} }

// Can create a set of memoized selectors based on the location of this entity state
const booksSelectors = booksAdapter.getSelectors<RootState>(
  (state) => state.books
)

// And then use the selectors to retrieve values
const allBooks = booksSelectors.selectAll(store.getState())

Parameter

selectId

単一のエンティティインスタンスを受け入れ、内部にある一意のIDフィールドの値を返す関数。指定しない場合、デフォルトの実装はentity => entity.idです。エンティティタイプがentity.id以外のフィールドに一意のID値を保持する場合は、selectId関数を指定する必要があります。

sortComparer

2つのエンティティインスタンスを受け入れるコールバック関数。標準のArray.sort()数値結果(1、0、-1)を返し、並べ替えの相対的な順序を示す必要があります。

提供されている場合、state.ids配列はエンティティオブジェクトの比較に基づいて並べ替えられた順序で保持されるため、ID配列をマッピングしてIDでエンティティを取得すると、エンティティの並べ替えられた配列になります。

Redux Style Guid

https://redux.js.org/style-guide/style-guide/

優先度A : 必須

  • stateを直接書き換えない
  • reducerに副作用を持たせない
  • シリアライズできない値をstateやactionに入れない
  • storeはひとつのアプリにつきひとつだけ

Actionに関するルール

  • actionをsetterではなくイベントとしてモデリングする
  • actionの名前は意味を的確に表現したものにする
  • action タイプ名を「ドメインモデル/イベント種別」のフォーマットで書く(キャメルケース)
    • Redux ToolkitのcreateSliceではdomain/actionの形式でactionを生成する
  • actionをFSAに準拠させる
  • dispatchするactionは直に書かずaction createrを使って生成する

ツールやデザインパターンの利用に関するルール

  • Reduxのロジックを書くときはRedux Toolkitを使う
  • イミュータブルな状態の更新にはImmerを使う
  • デバッグにはRedux DevTools拡張を使う
  • ファイル構造には「Feature Folder」またはDucksパターンを適用する

その他設計に関するルール

  • どの状態をどこに持たせるかは柔軟に考える
  • フォームの状態をReduxに入れない
  • 複雑なロジックはコンポーネントの外に追い出す
  • 非同期処理にはRedux Thunkを使う