Redux Toolkit Integration to my Sample Project

2021.09.03

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

Introduction

As part of learning, I recently implemented a sample project which includes hands-on exposure to React, Typescript, Firebase Authentication, and Redux. The sample project has a simple login functionality with Google Sign-in. I have handled the Authentication using Firebase and maintained the state of the user using Redux(Redux-Toolkit).

Redux is a library that enables predictable state containers for Javascript apps. It goes with React, Angular, Vue, or even vanilla JavaScript.

Why Redux, when react could handle states?

React can manage the state, but it is very tedious. let’s consider an example where component A gets in a user name and the same must be displayed in component B, then the state is lifted a level by component C. Now component C manages the state and passes it to component B as props. At times if component X needs the same user name, then the state must be handled the same way as component C (pull and hold the state) by the application and the required value must traverse all the way and reach component X. Components on the way must also carry the props even though it’s not required. It is complex and unnecessary for a couple of components to hold the unnecessary props. Whereas in Redux the state of an object is managed exclusively in a container where any component can access the required state.

Why Redux Toolkit, when Redux serves the same purpose?

Down the line, Redux was known for its complexity. It needed too many packages to work with React, configuring the store was not simple and Redux required too much boilerplate code. Whereas, Redux Toolkit comes up with simple options to configure the global state of the store and also create both actions and reducers in more simpler manner.

Prerequisites

Set up a React Typescript project. The below commands help us to set up a boilerplate code for React Typescript and also Redux Toolkit.

# NPM
npx create-react-app my-app typescript
cd my-app
npm install @reduxjs/toolkit
# Yarn
yarn create-react-app my-app --template typescript
cd my-app
yarn add @reduxjs/toolkit

For the sample project, the following were the version of tools used:

- Typescript 4.3.5
- React 17.0.2
- Redux 4.1.0
- Redux-Toolkit 1.6.0
- react-router-dom: 5.2.0
- firebase: 8.7.1
- react-google-button: 0.7.2
- emotion: 11.3.0

Redux Toolkit

Redux Toolkit is an abstract mechanism for working as and with Redux. It doesn't change any flow of Redux, rather streamlines existing Redux API functions in a simpler and readable manner.

Redux Toolkit includes libraries that are used to implement the Redux API. Namely, Redux: For global state management. Reselect: For selecting a slice among the existing global state. Immer: For handling immutability in stores. Redux-Thunk: For handling Async tasks.

Redux Toolkit's configureStore: Creates a Redux store instance like the createStore function of Redux, additionally instantiates other development tools and middlewares automatically.

Redux toolkit's createSlice includes the functionality of redux's createAction and createReducer where actions and reducers are implemented in a simple way rather than managing every action and its corresponding action inside the reducer. Within createSlice, we handle the initial state, reducer function to return reducer, action types, and action creators. createSlice excludes the functionality of the switch case to identify the action.

Redux toolkit's createAsyncThunk function handles the async actions. createAsyncThunk accepts an action type string identifier and a payload creator callback function which is an async logic that returns a promise and action types that you handle in your reducers.

Redux Toolkit Implementation

store.ts

We created a file called store.ts under src. This is the global store of our redux-toolkit. By using the configureStore method from @reduxjs/toolkit. we can configure the required number of reducers to our store. In my sample project I have defined a reducer auth: authSlice.reduce for handling our authentication of the user.

We created a type-casted selector for our store by using the useSelector hook from react-redux.

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

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useSelector: TypedUseSelectorHook<RootState> = rawUseSelector;

slices/auth.ts

We created a folder called slices under src and added a file named auth.ts where we are handling authentication states of users, which is an action returned by a promise.

We have an interface defined which handles the state objects of our reducer.

We are using the createAsynThunk function to create asynchronous functions such as login and logout to handle the promises(in-built Firebase functions).

With the createSlice function, we are handling actions returned by promises. In my project, I am handling two states of Promise (Promise.fulfilled, Promise.rejected). After executing our login or logout function which is created using createAsyncThunk we shall get the payload based on the user status. On promise being successful Redux executes the corresponding function (Promise.fulfilled) and modifies the state values such as displayName, email, etc accordingly. Else, if the promise fails then Redux executes the Promise.rejected function and sets the error based on the payload error.

import {
  createAsyncThunk,
  createSlice,
  SerializedError,
} from '@reduxjs/toolkit';
import firebase from 'firebase';

export interface AuthState {
  displayName?: string | null;
  email?: string | null;
  authenticated?: boolean;
  error?: SerializedError;
}

const initialState: AuthState = {
  displayName: undefined,
  email: undefined,
  authenticated: undefined,
  error: undefined,
};

interface PayLoad {
  displayName?: string | null;
  email?: string | null;
}

const provider = new firebase.auth.GoogleAuthProvider();

export const login = createAsyncThunk<AuthState, PayLoad>(
  'login',
  async (req, thunkAPI) => {
    try {
      if (req.displayName === null) {
        const response = await firebase.auth().signInWithPopup(provider);
        const displayName = response.user?.displayName;
        const email = response.user?.email;
        return { displayName, email } as PayLoad;
      } else {
        const displayName = req.displayName;
        const email = req.email;
        return { displayName, email } as PayLoad;
      }
    } catch (error) {
      return thunkAPI.rejectWithValue({ error: error.message });
    }
  }
);

export const logout = createAsyncThunk('logout', async (_, thunkAPI) => {
  try {
    await firebase.auth().signOut();
  } catch (error) {
    return thunkAPI.rejectWithValue({ error: error.message });
  }
});

export const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {},
  extraReducers: builder => {
    builder.addCase(login.fulfilled, (state, action) => {
      state.displayName = action.payload.displayName;
      state.email = action.payload.email;
      state.authenticated = true;
    });
    builder.addCase(login.rejected, (state, action) => {
      state.error = action.error;
    });
    builder.addCase(logout.fulfilled, state => {
      state.authenticated = false;
      state.displayName = initialState.displayName;
      state.email = initialState.email;
    });
    builder.addCase(logout.rejected, (state, action) => {
      state.error = action.error;
    });
  },
});

selectors/auth.ts

We created a folder named selectors under src and added a file auth.ts under it. This file has multiple functions that aid in retrieving the particular state value from our entire state object. We generally don’t need an entire state object hence, we destructured state object to return a particular value like auth.displayName. We also export these functions to be available throughout the application.

import { createSelector } from 'reselect';
import { RootState } from '../store';
import { AuthState } from '../slices/auth';

export const authSelector: (state: RootState) => AuthState = (
  state: RootState
) => state.auth;

export const displayNameSelector = createSelector(authSelector, auth => {
  return auth.displayName;
});

export const emailSelector = createSelector(authSelector, auth => {
  return auth.email;
});

export const isUserAuthenticatedSelector = createSelector(
  authSelector,
  auth => {
    return auth.authenticated;
  }
);

export const errorSelector = createSelector(authSelector, auth => {
  return auth.error;
});

Overall Project Implementation

index.tsx

After we create the store and slicers of Redux Toolkit, we should specify providing means to the application. In order to use our store globally we enclose our root component with the Provider from react-redux. Provider accepts store as props store={store} making it available to all the child components of the application.

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import firebase from 'firebase/app';
import { Provider } from 'react-redux';
import { store } from './store';

const firebaseConfig = {
  apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
  authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
  storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.REACT_APP_FIREBASE_APP_ID,
};

// Initialize Firebase
firebase.initializeApp(firebaseConfig);
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

App.tsx

import React from 'react';
import { Routes } from './Routes';

const App: React.FunctionComponent = () => {
  return <Routes />;
};

export default App;

Routes.tsx

In Routes.tsx component I used isUserAuthenticatedSelector from selectors/auth.ts which returns the authenticated value of the current user. I stored that value in a constant variable and used it to change the routes accordingly. I used the useDispatch hook from react-redux to dispatch the asynchronous functions.

import React, { useEffect, VFC } from 'react';
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
import { Login } from './components/screens/Login/Login';
import { LandingPage } from './components/screens/LandingPage/LandingPage';
import { ErrorsPage } from './components/screens/LandingPage/ErrorsPage';
import { useDispatch } from 'react-redux';
import { useSelector } from './store';
import { isUserAuthenticatedSelector } from './selectors/auth';
import firebase from 'firebase';
import { login, logout } from './slices/auth';
import { Loading } from './components/screens/Login/Loading';

export const Routes: VFC = () => {
  const authenticated = useSelector(isUserAuthenticatedSelector);
  const dispatch = useDispatch();

  const refresh = React.useCallback(
    async (displayName, email) => {
      const userData = {
        displayName,
        email,
      };
      return dispatch(login(userData));
    },
    [dispatch]
  );

  useEffect(() => {
    const f = async () => {
      firebase.auth().onAuthStateChanged(async user => {
        if (user && !authenticated) {
          return await refresh(user.displayName, user.email);
        }
        if (!user && !authenticated) {
          dispatch(logout());
        }
      });
      await firebase
        .auth()
        .setPersistence(firebase.auth.Auth.Persistence.SESSION);
    };
    f();
  });

  if (authenticated === undefined) {
    // "unconfirmed" authentication status
    return <Loading />;
  } else {
    // login user Router
    return (
      <Router>
        <Switch>
          <Route exact path='/' component={Login} />
          <Route path='/landingpage' component={LandingPage} />
          <Route component={ErrorsPage} />
        </Switch>
      </Router>
    );
  }
};

Login.tsx

import React, { VFC } from 'react';
import styled from '@emotion/styled';
import GoogleButton from 'react-google-button';
import logoCxPassport from '../../../assets/logo_cx_passport.png';
import { login } from '../../../slices/auth';
import { useDispatch } from 'react-redux';
import { Redirect } from 'react-router-dom';
import { isUserAuthenticatedSelector } from '../../../selectors/auth';
import { useSelector } from '../../../store';

const Wrapper = styled.div`
  display: flex;
  align-items: center;
  justify-content: center;
`;
const Container = styled.div`
  display: block;
  align-items: center;
  justify-content: center;
  padding-top: 300px;
  padding-bottom: 30px;
`;
const LogoImage = styled.img`
  height: 40px;
  display: block;
  margin-right: auto;
  margin-left: auto;
  padding-bottom: 15px;
`;
export const Login: VFC = () => {
  const authenticated = useSelector(isUserAuthenticatedSelector);
  LogoImage.defaultProps = {
    src: String(logoCxPassport),
  };
  const dispatch = useDispatch();
  const appLogin = () => {
    const userData = {
      displayName: null,
      email: null,
    };
    dispatch(login(userData));
  };
  if (authenticated) {
    return <Redirect to='/landingpage' />;
  }
  return (
    <Wrapper>
      <Container>
        <LogoImage />
        <GoogleButton onClick={appLogin} />
      </Container>
    </Wrapper>
  );
};

Conclusion

Redux Toolkit is a simple and efficient library over redux which serves the same purpose as simplified code. For reference, see more information on the official Redux Toolkit page. Happy Learning!