React + Typescript プロジェクトに Redux Toolkit を導入したので使い方をざっくりとまとめてみる

2021.06.20

Redux を導入する際にネックとなるのが、State、Action、Selector、Reducer というそこそこの量のボイラープレートコードを記述しなければならないことです。可読性を考慮してそれぞれファイルを分けて記述するとフォルダ構成も考えないといけないので導入するハードルがそこそこ高いと個人的に感じていました。

今回 Redux Toolkit という Redux のヘルパーライブラリを使ってみて上記の悩みが解消されたので使い方をまとめてみました。

English Version of this article is also available : How to use Redux Toolkit for React + Typescript projects.

Redux Toolkit とは

Redux のメインメンテナーであり、Redux Toolkit 作者のMark Eriksonさんはこのツールを以下の意図で設計したと書いています。

1. Reduxをより簡単に使い始めることができる
2. Reduxの一般的なタスクやコードを簡素化する
3. 意見のあるデフォルトを使用して、"ベストプラクティス "を導きます。
4. Reduxを使用する上で懸念される「定型文」を削減または排除するためのソリューションを提供する。

参照元:Idiomatic Redux: Redux Toolkit 1.0

より抽象化された関数を使うことで、全体のコードの記述量が減り、Redux の導入ハードルが下がること、非同期処理を記述する際に使う redux-thunk や Chrome のコンソールから Store の状態を見るための redux-devtoolsreselectなどの Redux 開発でよく利用するライブラリがあらかじめ入っていることなどが Redux Toolkit の大きな特徴です。

また、Redux Toolkit には Slicer という概念があり、ここに Reducer 関数、initialState の値を渡すと対応する action types と reducer を作成してくれます。

定型的なコードを抽象化してくれるのでコードの記述量を減らすことが可能です。

Getting Started

本記事では create-react-app で作成した雛形に LINE API からユーザーの情報を取得し画面に表示する簡単なアプリを作成します。API から取得した内容を Redux Store に保存し、Selector でコンポーネントへ値を渡すところを Redux Toolkit を使ってやってみます。

React プロジェクトの雛形を作成

Typescript を指定してプロジェクトの雛形を作成します。

npx create-react-app redux-toolkit-sample --typescript
cd redux-toolkit-sample

Redux Toolkit 導入する

# NPM
npm install @reduxjs/toolkit

# Yarn
yarn add @reduxjs/toolkit

LIFF SDK を追加

LINE LOGIN 機能を利用するのに@line/liffが必要なのでインストールしておきます。

yarn add @line/liff

LINE Developers Console でチャネル を追加

LINE Developers Console でチャネルを作成しておく必要があります。

Scope にはopenid, profileの両方を指定してください。 今回は ローカル環境 からアプリを開く想定なので、エンドポイント URL にhttps://localhost:3000を指定しています。

後にアプリケーションから参照するので、ここで作成した LIFF ID をenv.development.localファイルに以下のように保存しておいてください。

REACT_APP_LIFF_ID="<YOUR_LIFF_ID>"

LINE チャネルの作成について詳しくはこちらの記事を参考にしてみてください。

LINE API に関してはサードパーティ API から値を取得して Redux Store に保存する、という目的のために利用しているだけなので、別の API を利用して試す場合はこの作業は必須ではありません。

Store を作成

src下にstore.tsを作成します。auth という名前の store に LINE API から取得したデータを保持します。

import {configureStore} from "@reduxjs/toolkit";
import {useSelector as rawUseSelector, TypedUseSelectorHook} from "react-redux";
import {authSlice} from "./slices/auth";

export const store = configureStore({
  reducer: {
    auth: authSlice.reducer,
  },
});

// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;

export const useSelector: TypedUseSelectorHook<RootState> = rawUseSelector;

最後の行の useSelector はこちらの記事を参考にカスタム版の useSelector を定義しており、こう記述することで store から State の型が補完されるようになります。

Slice を作成

次にslicesディレクトリを作成してauth.tsを以下のように作成します。

import {createAsyncThunk, createSlice, SerializedError} from "@reduxjs/toolkit";
import liff from "@line/liff";

const liffId = process.env.REACT_APP_LIFF_ID;

export interface AuthState {
  liffIdToken?: string;
  userId?: string;
  displayName?: string;
  pictureUrl?: string;
  statusMessage?: string;
  error?: SerializedError;
}

const initialState: AuthState = {
  liffIdToken: undefined,
  userId: undefined,
  displayName: undefined,
  pictureUrl: undefined,
  statusMessage: undefined,
  error: undefined,
};

interface LiffIdToken {
  liffIdToken?: string;
}

interface LINEProfile {
  userId?: string;
  displayName?: string;
  pictureUrl?: string;
  statusMessage?: string;
}

// LINE Login
export const getLiffIdToken = createAsyncThunk<LiffIdToken>(
  "liffIdToken/fetch",
  async (): Promise<LiffIdToken> => {
    if (!liffId) {
      throw new Error("liffId is not defined");
    }
    await liff.init({liffId});
    if (!liff.isLoggedIn()) {
      // set `redirectUri` to redirect the user to a URL other than the endpoint URL of your LIFF app.
      liff.login();
    }
    const liffIdToken = liff.getIDToken();
    if (liffIdToken) {
      return {liffIdToken} as LiffIdToken;
    }
    throw new Error("LINE login error");
  },
);

// Get LINE Profile
export const getLINEProfile = createAsyncThunk<LINEProfile>(
  "lineProfile/fetch",
  async (): Promise<LINEProfile> => {
    const lineProfile = liff.getProfile();
    if (lineProfile) {
      return lineProfile as LINEProfile;
    }
    throw new Error("LINE profile data fetch error");
  },
);

export const authSlice = createSlice({
  name: "auth",
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(getLiffIdToken.fulfilled, (state, action) => {
      state.liffIdToken = action.payload.liffIdToken;
    });
    builder.addCase(getLiffIdToken.rejected, (state, action) => {
      state.error = action.error;
    });
    builder.addCase(getLINEProfile.fulfilled, (state, action) => {
      state.userId = action.payload.userId;
      state.displayName = action.payload.displayName;
      state.pictureUrl = action.payload.pictureUrl;
      state.statusMessage = action.payload.statusMessage;
    });
    builder.addCase(getLINEProfile.rejected, (state, action) => {
      state.error = action.error;
    });
  },
});

LINE Login API から取得したトークンも LINE の Profile 情報も全て1つの store に入れてしまっていますが、ここは分けても良いと思います。

redux-toolkit には Immer が入っているので以下のように書く必要がありません。

*Immer を使わない書き方

builder.addCase(getLINEProfile.fulfilled, (state, action) => {
  return {
    ...state,
    userId: action.payload.userId,
    displayName: action.payload.displayName,
    pictureUrl: action.payload.pictureUrl,
    statusMessage: action.payload.statusMessage,
  };
});

Selector を追加

Redux Store から欲しいデータを取得するための Selector を定義します。redux-toolkit には reselect が入っているので個別にインストールする必要はありません。

import {createSelector} from "reselect";
import {RootState} from "../store";

// auth情報を取得するSelector
export const authSelector = (state: RootState) => state.auth;

/**
 * liffIdTokenを取得する
 * @returns liffIdToken
 */
export const liffIdTokenSelector = createSelector(authSelector, (auth) => {
  return auth.liffIdToken;
});

/**
 * LINEの名前を取得する
 * @returns displayName
 */
export const displayNameSelector = createSelector(authSelector, (auth) => {
  return auth.displayName;
});

/**
 * LINEの画像を取得する
 * @returns pictureUrl
 */
export const pictureUrlSelector = createSelector(authSelector, (auth) => {
  return auth.pictureUrl;
});

/**
 * errorを取得する
 * @returns error
 */
export const errorSelector = createSelector(authSelector, (auth) => {
  return auth.error;
});

このように欲しい情報に分けて reselect で Selector をきちんと定義することで action が Dispatch された時、必要な情報が更新された時にのみ Selector 関数を再計算してコンポーネントを再レンダーしてくれます。

コンポーネントを追加して外部 API を呼んでみる

LINE の displayName と画像を表示するコンポーネントを追加します。Redux とやりとりをするロジックの部分は Container に切り出しています。

components/Home/index.tsx

import {SerializedError} from "@reduxjs/toolkit";
import React, {useEffect, VFC} from "react";

interface Props {
  liffIdToken?: string;
  displayName?: string;
  pictureUrl?: string;
  error?: SerializedError;
  lineLogin: () => void;
  lineProfile: () => void;
}

export const Home: VFC<Props> = (props: Props) => {
  const {liffIdToken, displayName, pictureUrl, lineLogin, lineProfile, error} =
    props;
  useEffect(() => {
    // LINE Login
    if (!liffIdToken) {
      // liffIdToken がReduxに取得できていない場合LINE Login画面に戻る
      lineLogin();
    }
  }, [liffIdToken, lineLogin]);

  useEffect(() => {
    if (liffIdToken) {
      // LINE Profile情報を取得
      lineProfile();
    }
  }, [liffIdToken, lineProfile]);

  if (error) {
    return (
      <div>
        <p>ERROR!</p>
      </div>
    );
  } else
    return (
      <div className="App">
        <header className="App-header">
          <img src={pictureUrl} alt="line profile" width="80" height="80" />
          <p>HELLO, {displayName}</p>
        </header>
      </div>
    );
};

containers/Home/index.tsx

import React, {FC, useCallback} from "react";
import {useDispatch} from "react-redux";
import {useSelector} from "./../store";
import {Home} from "../components/Home";
import {getLiffIdToken, getLINEProfile} from "../slices/auth";
import {
  displayNameSelector,
  errorSelector,
  liffIdTokenSelector,
  pictureUrlSelector,
} from "../selectors/auth";

export const HomeContainer: FC = () => {
  const dispatch = useDispatch();
  const liffIdToken = useSelector(liffIdTokenSelector);
  const displayName = useSelector(displayNameSelector);
  const pictureUrl = useSelector(pictureUrlSelector);
  const error = useSelector(errorSelector);
  const lineLogin = useCallback(() => {
    dispatch(getLiffIdToken());
  }, [dispatch]);
  const lineProfile = useCallback(() => {
    dispatch(getLINEProfile());
  }, [dispatch]);

  return (
    <Home
      liffIdToken={liffIdToken}
      displayName={displayName}
      pictureUrl={pictureUrl}
      lineLogin={lineLogin}
      lineProfile={lineProfile}
      error={error}
    />
  );
};

App.tsxを以下のように編集します。

import React from "react";
import {HomeContainer as Home} from "../src/container/Home";
import "./App.css";

function App() {
  return (
    <div className="App">
      <Home />
    </div>
  );
}

export default App;

Reduxの状態を確認する

Chromeにredux-devtoolsが入っている場合はコンソールからstoreの状態を確認することができます。Home画面にアクセスして、liff.init()、liff.getProfile()のリクエストが成功している場合はstoreにデータが格納されます。

失敗した場合はerrorにエラー内容が入ります。

ファイル構成

全体のファイル構成は以下の通りです。

redux-toolkit-sample
 ├── node_modules
 │    └── ...
 ├── public
 │    └── ...
 ├── src
 │    ├── components
 │         └── Home/index.tsx
 │    ├── containers
 │         └── Home.tsx
 │    ├── selectors
 │         └── auth.ts
 │    ├── slices
 │         └── auth.ts
 │    ├── App.tsx
 │    ├── App.css
 │    ├── index.tsx
 │    └── store.ts
 ├── .env.development.local
 ├── .gitignore
 ├── package.json
 ├── README.md
 ├── yarn.lock

課題

Slice という概念が加わったのでファイル構成を少し悩みます。今回はあまり考慮せず作成しましたが、Ducks や re-ducks 形式にしてみた方がわかりやすかったかもしれません。

Redux を導入するタイミング

大量の定型的なコードを書かなくてはならないこともあり、以前は「Store に保持しなければならないデータが一定量あるなら導入を検討しようかな・・・」と考える程度には Redux 導入にはハードルがありました。Redux ToolKit を使うことで以前より手軽に導入を検討できるようになった気がします。実案件でもプロジェクト初期から Redux を導入しています。Store にどのデータをどう保持していくかを初めにある程度洗い出すことで全体の設計の見通しがしやすくなったと感じました。また使っているうちに気づきがあったらブログにまとめたいと思います。

参考

以下の記事がめちゃくちゃ参考になりました!ありがとうございました。