💡gntk.dev

りあクト!でReduxを学ぶ

りあクト!を読んでReduxを学びました。Reduxとはなにか?なぜ必要か?仕組みと使い方についてまとめました。

post-cover

りあクト!を読んでReduxを学んだので内容を自分なりにまとめ。

Reduxとは

Reactはコンポーネントを組み合わせてアプリケーションを作っていくためのライブラリです。コンポーネントには「状態」を持つものがあり、「状態」はそのコンポーネント自身だけではなく親や子のコンポーネントの見た目や振る舞いにも影響を与えます。

Reduxが必要な理由

(以下画像はWhen do I know I’m ready for Redux?から引用しています)

親コンポーネントの状態を子コンポーネントと共有する方法

親コンポーネントの状態を子コンポーネントと共有する方法として、親コンポーネントに必要な状態を持たせておいて、子孫のコンポーネントにそれらの状態をPropsとして渡す方法があります。(React公式:https://ja.reactjs.org/docs/state-and-lifecycle.html#the-data-flows-down)

状態を兄弟コンポーネントで共有する場合

状態を兄弟のコンポーネントで共有したい場合、共通の親コンポーネントに状態を持たせます。親コンポーネントはContainer Componentと呼ばれ、子コンポーネントに状態と、状態を変更させるための関数を渡します。子コンポーネントが状態を変更させると、親コンポーネントを経由して別の子コンポーネントに状態が共有されます。(React公式:https://ja.reactjs.org/docs/lifting-state-up.html)

親子でも兄弟でもないコンポーネントと状態を共有する方法

親子でも兄弟でもないコンポーネントと状態を共有するためには、それぞれのコンポーネントの最も近い祖先をContainer Componentとし、同様に状態を共有します。アプリケーションが大きくなればなるほどこの関係は複雑になっていき、手に負えなくなっていきます。

Reduxで状態を管理する方法

Reduxは、コンポーネントのツリーの外側に状態を管理する仕組みを用意することでこの問題を解決するためのライブラリです。

Reduxのしくみ

(以下画像はRedux公式から引用しています)

Reduxのしくみ

ただコンポーネントのツリーの外側に状態を持たせるだけであれば、言ってしまえばそれはグローバル変数で、あちこちのコンポーネントから好き勝手読み書きさせてしまうとそれこそ手に負えなくなってしまいます。どのコンポーネントがどの状態を見ているのか、どんなタイミングでどのコンポーネントから状態が変更されるかわからなくなる、など…。そこでReduxでは、3つの原則を掲げ、それを実現するための仕組みを用意しています。

Single source of truth

Reduxでは、アプリケーションの状態を管理するstoreにオブジェクトツリーstateとそれを更新するための関数reducerを持ち、それらを使って状態を管理します。そして、storeはアプリケーション内に1つしか存在しないことが保証されています。storeが複数あると、storeどうしのやり取りが発生して取り扱いが複雑になってしまいます。storeが1つしか存在しないことが保証されていれば、storeが複数ある場合と比べて取り扱いは単純になりますし、デバッグがやりやすくなったり、テストしやすくなったりします。

State is read-only

stateは読み込み専用で、直接書き込み(状態を変更)することはできません。Reduxでは、stateの状態を変更することができるのはreducerのみです。前述の通りstoreはアプリケーションにたった1つしかないため、reducerもたった1つしかありません。どのコンポーネントから状態を変更することになってもすべての変更は必ず1つのreducerに集約され、厳密な順序で1つずつ処理されます。そのため、すべての処理は競合しません。また、処理のログを取ることで、デバッグの際に参照したり、処理を再現してテストに利用できたりします。

Changes are made with pure functions

reducerはstate(変更前の状態)とaction(変更内容)を受け取ってstate(変更後の状態)を返す純粋関数です。純粋関数であるということは、内部に状態を持たず、ある入力を受け取れば必ず決まった出力を返す関数であるということです。actionは、stateをどのように変更したいかという内容が示されたオブジェクトです。また、actionはdispatcherがUIからイベントを受け取って生成します。UIがreducerに直接actionを渡すのではなくdispatcherを経由することで、UIからstoreの関心を分離することができています。

Reduxの使い方

コードベースで、実際のReduxの使い方を解説します。

Reduxをコンポーネントに提供する

import { createStore } from 'redux';
import { Provider } from 'react-redux';
import { counterReducer, initialState } from 'reducer';

const store = createStore(counterReducer, initialState);

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root') as HTMLElement,
);

Provider

上位コンポーネントにProviderを設置すれば、子孫コンポーネントでreduxの機能をHooks APIを使って利用できる。

Providerには初期化したstoreをPropsとして渡す。

storeの初期化

createStoreを使ってstoreを初期化する。

createStoreには定義したreducerとstoreの初期値を渡す。counterReducer(定義したreducer)とinitialState(storeの初期値)をreducer.tsからimportする。

reducerを定義する

import { Reducer } from 'redux';
import { CounterAction, CounterActionType as Type } from 'actions';

export type CounterState = {
  count: number;
};

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

export const counterReducer: Reducer<CounterState, CounterAction> = (
  state: CounterState = initialState,
  action: CounterAction,
): CounterState => {
  switch (action.type) {
    case Type.ADD:
      return {
        ...state,
        count: state.count + (action.amount || 0),
      };
    case Type.DECREMENT:
      return {
        ...state,
        count: state.count - 1,
      };
    case Type.INCREMENT:
      return {
        ...state,
        count: state.count + 1,
      };
    default: {
      const _: never = action.type;

      return state;
    }
  }
};

initialState(storeの初期値)、counterReducer(定義したreducer)とその返り値の型をexportする。

reducerの内容

例に上げているcounterReducerは

  • state(更新前の状態)とaction(更新処理に必要な情報)を受け取る

    • stateを受け取らなかった場合initialStateを初期値として設定
    • それぞれの型はReducer型のジェネリクスで設定
  • CounterState型のstate(更新後の状態)を返す
  • action.typeによってstateの更新処理をswitchする

    • Type.ADDと同じだった場合、任意の値を加算
    • Type.DECREMENTと同じだった場合、1減算
    • Type.INCREMENTと同じだった場合、1加算
    • どれでもなかった場合stateをそのまま返す(counterにCounterAction型アノテーションをつけているため、case文を書き漏らさない限りdefaultには落ちない。case文を書き漏らした場合、never型の変数に代入しようとするため、実際はreturnに行き着く前にコンパイルエラーになる。case文の漏れを未然にチェックするための手法。)

という内容。更新前の状態更新に必要な情報を受け取って更新後の状態を返す関数。

更新処理に必要な情報(CounterAction型で表現されている)と更新処理の種類(CounterActionType)はactions.tsからimportする。(「種類」のTypeと「型」のTypeがごっちゃになってわかりづらくて嫌…)

actionsを用意する

export const CounterActionType = {
  ADD: 'ADD',
  DECREMENT: 'DECREMENT',
  INCREMENT: 'INCREMENT',
} as const;

type ValueOf<T> = T[keyof T];

export type CounterAction = {
  type: ValueOf<typeof CounterActionType>;
  amount?: number;
};

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

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

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

CounterActionType

reducerに渡すための「更新処理の種類」を表したオブジェクトです。as constをつけることで、CounterActionTypeを定数にする(CounterActionType.ADD = xxxがエラーになる)ことができます(constアサーション)。これをすると、CounterActionTypeの型が{ ADD: string; DECREMENT: string; INCREMENT: string; }ではなく{ readonly ADD: "ADD"; readonly DECREMENT: "DECREMENT"; readonly INCREMENT: "INCREMENT"; }になり、タイポによるバグ発生のリスクを減らすことができます。

CounterAction

まず、reducerに渡したいactionとは何かというと、プレーンなオブジェクトです。例えばcounterStateのcountに1を加算したいときは{type: 'INCREMENT'}、counterStateのcountに5(任意の値amountとする)を加算したいときは{type: 'ADD', amount: 5}をreducerに渡す、といった具合です。そのプレーンなオブジェクトの型を表現しているのがこのCounterActionです。

type

ValueOf<T>という型の関数?のようなものを用意して、オブジェクトの値から型を抽出してユニオン型を作っています。これによって、typeの型は"ADD" | "DECREMENT" | "INCREMENT"になります。これの詳しい解説は「りあクト!TypeScriptで始めるつらくないReact開発」第4章、第5節内の「型表現に使われる演算子」にありますので是非ご購入になって読んでください(りあクト!は最高に丁寧でわかりやすい技術書です)。

amount

actionがaddだった場合、amountプロパティが必要です。increment, decrementだった場合必要ないため、?をつけて省略可能にしています。

add, decrement, increment

これらは、Action Creatorと呼ばれる関数です(「Reduxのしくみ」で説明したdispatcherです)。コンポーネントからstateの値を変更するときは、reducerに直接{type: 'ADD', amount: 5}のようなオブジェクトを渡すのではなく、Action Creatorを呼んでその返り値として得たオブジェクトをactionとしてreducerに渡します。

コンポーネントからstateの値を取得/変更する

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

import { add, decrement, increment } from 'actions';
import { CounterState } from 'reducer';
import ChildComponent from 'components/ChildComponent';

const Counter: FC = () => {
  const count = useSelector<CounterState, number>((state) => state.count);
  const dispatch = useDispatch();

  return (
    <ChildComponent
      count={count}
      add={(amount: number) => dispatch(add(amount))}
      decrement={() => dispatch(decrement())}
      increment={() => dispatch(increment())}
    />
  );
};

useSelectoruseDispatchはreduxが提供するHooks APIです。useSelectoruseDispatchを使って取得したcountとAction Creatorを子コンポーネントに渡します。

useSelector

useSelectorを使ってstateからcountを取り出します。引数としてstateからcountを抽出する関数(state) => state.countを渡します。また、ジェネリクスの第1引数にはstate全体の型、第2引数には取り出したいstateの型を渡します。

useDispatch

useDispatchにactionを渡してstateを更新するための関数を取得しています。dispatch({ type: 'INCREMENT' }) などすればINCREMENTに紐づくactionが実行されますが、バグを防ぐため、直接actionを渡すのではなく用意したAction Creator関数の返り値を使うようにします。

まとめ

りあクト!はベテランエンジニアと新米エンジニアの対話形式で最新のReact/TypeScriptを学べる最高の技術書で、この記事の500倍広く深くわかりやすいので、ぜひ購入してください。

© 2021 gntk.dev