How to use Redux Toolkit for React + Typescript projects.

2021.06.27

One of the bottlenecks using Redux is the number of boilerplates that need to be written: State, Action, Selector, and Reducer. This time, I tried using Redux Toolkit, a Redux helper library, and it helped me to use Redux easier and simpler than using regular Redux. In this article, I summarized how to use Redux Toolkit in a React project.

What is the Redux Toolkit?

The main maintainer of Redux and author of the Redux Toolkit, Mark Erikson, writes that he designed this tool with the following intentions.

1. make it easier to get started with Redux.
2. simplify common Redux tasks and code.
3. use opinionated defaults to guide you to "best practices
4. provide solutions to reduce or eliminate "boilerplate" concerns in using Redux.

Reference: Idiomatic Redux: Redux Toolkit 1.0

The use of more abstract functions reduces the overall amount of code, lowering the hurdle to using Redux, as well as including common libraries such as redux-thunk for writing asynchronous processing and redux-thunk for viewing Store status from Chrome's console. redux-devtools](https://github.com/zalmoxisus/redux-devtools-extension) and reselect to view the Store status from the Chrome console.

In addition, the Redux Toolkit has the concept of Slicer, which creates the corresponding action types and reducers bypassing the Reducer function and initialState value to it. It abstracts the commonly required code and reduces the amount of code that the developer needed to in regular Redux development.

Getting Started

In this article, we will create a simple application that retrieves user information from the LINE API and displays it on the screen, using React project template created with create-react-app and Redux Toolkit.

Create a new React project with create-react-app

Create a project skeleton by specifying a Typescript with create-react-app command.

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

Add Redux Toolkit to your project

# NPM
npm install @reduxjs/toolkit

# Yarn
yarn add @reduxjs/toolkit

Add LIFF SDK

Add @line/liff in your project that is required to use LINE Login feature of LINE API.

yarn add @line/liff

Add a Channel in the LINE Developers Console

You will need to create a channel in the LINE Developers Console.

Specify both openid and profile for Scope. Since we are going to open the application from the local environment, we specify https://localhost:3000 as the endpoint URL.

Save the LIFF ID in the env.development.local file as follows, since we will refer to it later in the application.

REACT_APP_LIFF_ID="<YOUR_LIFF_ID>"

For more information on creating a LINE channel, please refer to this article.

As for the LINE API, we are only using it for the purpose of retrieving values from third-party APIs and storing them in the Redux Store, so if you want to try using a different API, this task is not required.

Create a Redux Store

Create store.ts under src. The store named auth will hold the data retrieved from the 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;

The last line, useSelector defines a custom version of useSelector in Redux based on this article. By setting a custome useSelector, the State type auto-completion from the store will be available.

Create Slice

Next, create the slices directory and create auth.ts file as follows.

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;
    });
  },
});

The tokens obtained from the LINE Login API and the LINE Profile information are all stored in one store, but I think it's fine to separate them here for better inplementation.

The redux-toolkit includes Immer, so you don't need to write anything like the following.

* Sample code writing without 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,
  };
});

Add Selectors

Define Selectors to get the specific data that you need from the Redux Store using reselect. reselect is already included in redux-toolkit, so there is no need to install it separately.

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

// get whole auth state
export const authSelector = (state: RootState) => state.auth;

/**
 * get liffIdToken
 * @returns liffIdToken
 */
export const liffIdTokenSelector = createSelector(authSelector, (auth) => {
  return auth.liffIdToken;
});

/**
 * get LINEdisplay name
 * @returns displayName
 */
export const displayNameSelector = createSelector(authSelector, (auth) => {
  return auth.displayName;
});

/**
 * get LINEimage url
 * @returns pictureUrl
 */
export const pictureUrlSelector = createSelector(authSelector, (auth) => {
  return auth.pictureUrl;
});

/**
 * get error data
 * @returns error
 */
export const errorSelector = createSelector(authSelector, (auth) => {
  return auth.error;
});

By properly defining the Selector with reselect according to the information you want, the Selector function will recalculate and re-render the component only when the action is dispatched and the necessary information is updated.

Add a component and call the external API

Add a component to display the LINE displayName and the image, and cut out the part of the logic that interacts with Redux into a 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}
    />
  );
};

Lastly, update App.tsxas below.

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;

Check the Redux Store in redux-devtool in Chrome console

If you have redux-devtools installed in Chrome, you can check the status of the store from the console. liff.init() and liff.getProfile() requests are successful, the data will be stored in the Redux store.

If the API request fails, there will be an error stored instead as below.

Whole project file structure

The whole project's file structure is shown in below.

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

About the file structure

Since the concept of Slice has been added by using Redux-Toolkit, the file structure is a bit of a problem. This time, I didn't give much thought to it, but it might have been easier to understand if I had used Ducks or re-ducks format.

Summary

The Redux ToolKit has made it easier for me to think about using Redux in the early stages of a project.

References

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