How to Set Up and Use TypeScript with Redux Toolkit

Learn how to set up and use TypeScript with Redux Toolkit. TypeScript and Redux Toolkit make a great combination for developing efficient and reliable web applications.

Although Redux is a common preference for managing state in a React application, it’s important to acknowledge that configuring Redux with its boilerplate code can be a tedious and frustrating process.

The Redux team came up with Redux Toolkit as an attempt to make implementing Redux less intimidating. And this time, the team chose TypeScript to build the library instead of JavaScript. Why? Because TypeScript offers type safety, code readability, and improved scalability — all of which make Redux better to use in complex apps.

This article will focus on the setup and usage of TypeScript with Redux Toolkit. We’ll cover:

  • Installing Redux for a new project
  • Setting up our store using configureStore()
  • Writing reducers and actions
  • Connecting to the store
  • Async actions with thunk, error handling, and loading states

Installing Redux for a new project

First, we’ll use Vite to set up our project:

npm create vite@latest ts-app -- --template react-ts 

If you’re curious about why Vite was chosen over Create React App, read more here.

Next, install the packages needed for Redux Toolkit:

npm i react-redux @reduxjs/toolkit

N.B., starting from React Redux v8.0.0, the react-reduxcodebase has been migrated to TypeScript and no longer requires @types/react-redux as a dependency.

You should end up with a project structure that looks like this:

React Redux Project Structure

Setting up our store using configureStore()

RTK provides configureStore(), a user-friendly abstraction over the standard Redux createStore(). configureStore() simplifies the store setup process by including some useful default configurations:

  • A preconfigured set of middleware, such as redux-thunk for handling asynchronous actions, and redux-immutable-state-invariant for detecting accidental mutations of the state
  • Creates the root reducer using the combineReducers utility if an object of slice reducers is passed directly
  • Generates the necessary code to enable the Redux DevTools extension in the browser

configureStore() takes in a single configuration object with the following options:

  • Reducer: A single reducer function or an object of slice reducers
  • Middleware: An optional array of middleware functions
  • DevTools: An optional Boolean to enable the use of Redux DevTools extension in the browser
  • PreloadedState: An optional initial state
  • Enhancers: An optional array of store enhancers to extend the functionality of our store beyond its implementation

Let’s set up our store:

// app/store.ts
import { configureStore } from '@reduxjs/toolkit';
export const store = configureStore({
  reducer: {
    // our reducers goes here
  },
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;

In the above code, configureStore() is being used to create and initialize the store with the provided reducer; we’ll add it later. To ensure type safety, AppDispatch and RootState typings are defined for the dispatch() and getState() of our store instance.

Now, we’ll create the typed versions of the useDispatch and useSelector Hooks:

// app/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Finally, provide the store to our app using Provider:

# src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import { Provider } from 'react-redux/es/exports'
import { store } from './app/store.ts'
import './index.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
)

Writing reducers and actions

Let’s start simple and create a reducer for adding a user to the store:

// src/features/users/userSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
export interface User {
  id: string;
  name: string;
  email: string;
}
const initialState: Array<User> = [
    {
        id: '1',
        name: 'John Doe',
        email: 'john@test.com',
    }
]
export const userSlice = createSlice({
  name: "users",
  initialState,
  reducers: {
    addUser: (state, action: PayloadAction<User>) => {
      state.push(action.payload);
    },
  },
});
export const { addUser } =
  userSlice.actions;
export const userSelector = (state: RootState) => state.userReducer;
export default userSlice.reducer;

In the above code, we used createSlice(), a utility function from Redux Toolkit that takes in the following:

  • The name of the slice
  • The initial state having type definition
  • An object of one or more reducer functions with its key being the name of the action that will be dispatched to trigger the reducer, and the value being the reducer function itself

Each reducer function takes two arguments: the current state of the slice and the action object that was dispatched. The reducer function returns a new state object that represents the updated state of the slice.

createSlice() generates a set of actions, reducers, and action creators for a particular slice of the store. The selectors — in this case, userSelector — will enable us to get this slice of the store in any part of the component tree.

When the reducer is ready, pass it in configureStore():

// app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import userReducer from '../features/users/userSlice';
export const store = configureStore({
  reducer: {
    userReducer
  },
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;

Connecting to the store

We’re going to need the typed hooks we defined earlier in order to connect our components to the store:

// src/features/users/user.tsx
import { useEffect, useState } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { User, addUser, userSelector } from "./userSlice";
import "./user.css";
function UserPage() {
  const [users, setUsers] = useState<Array<User>>([]);
  const [newUserName, setNewUserName] = useState<string>("");
  const [newUserEmail, setNewUserEmail] = useState<string>("");
  const selectedUsers = useAppSelector(userSelector);
  const dispatch = useAppDispatch();
  useEffect(() => {
    setUsers(selectedUsers);
    return () => {
      console.log("component unmounting...");
    };
  }, [selectedUsers]);
  function handleAddUser() {
    const newUser = {
      id: (users.length + 1).toString(),
      name: newUserName,
      email: newUserEmail,
    };
    dispatch(addUser(newUser));
  }
  return (
    <div>
      {users.map((user) => (
        <li key={user.id}>
          {user.id} | {user.name} | {user.email}
        </li>
      ))}
      <div>
        <input
          type="text"
          placeholder="Name"
          aria-label="name"
          value={newUserName}
          onChange={(e) => setNewUserName(e.target.value)}
        ></input>
        <input
          type="text"
          placeholder="Email"
          aria-label="email"
          value={newUserEmail}
          onChange={(e) => setNewUserEmail(e.target.value)}
        ></input>
        <button type="submit" className="btn" onClick={handleAddUser}>
          Add
        </button>
      </div>
    </div>
  );
}
export default UserPage;

In the above code, we’re using useAppSelector to fetch the users slice from the store. And to dispatch an action, we’re using useAppDispatch. The rest of the code is more React-related to add a new user to the store, and render the list of users every time the users slice in the store is updated.

This is how it looks:

Adding User Components To Our React Store

If we make use of the Redux DevTools extension in the browser, we can see a detailed logging from the initial state to the updated state:

Logging With Redux DevTools

Async actions with thunk, error handling, and loading states

So far, we have only dealt with synchronous actions to update the store. It’s time to learn how to handle asynchronous actions.

Under the hood, Redux Toolkit uses redux-thunk for handling async logic. But what exactly is redux-thunk?

Thunk is a programming concept that involves the usage of a function to delay the evaluation of an operation. In the context of Redux, thunk is a function returned by an action creator, which can be used to delay the dispatch of an action. With middleware like redux-thunk and redux-saga, we can implement this behavior.

It’s time to code this. Let’s handle some async operation, such as fetching a list of users from an endpoint:

// src/features/user/userSlice.ts
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
interface User {
  id: string,
  name: string,
  email: string
}
export interface UserState {
  loading: boolean;
  users: Array<User>;
  error: string | undefined;
}
const initialState: UserState = {
  loading: false,
  users: [],
  error: undefined,
}
export const fetchUsers = createAsyncThunk(
  "users/fetchUsers",
  () => {
    const res = fetch('https://jsonplaceholder.typicode.com/users').then(data => data.json());
    return res;
  }
)
const userSlice = createSlice({
  name: 'users',
  initialState,
  extraReducers: (builder) => {
    builder.addCase(fetchUsers.pending, (state) => {
      state.loading = true;
    });
    builder.addCase(fetchUsers.fulfilled, (state, action: PayloadAction<Array<User>>) => {
      state.loading = false;
      state.users = action.payload;
    });
    builder.addCase(fetchUsers.rejected, (state, action) => {
      state.loading = false;
      state.users = [];
      state.error = action.error.message;
    });
  },
  reducers: {}
})
export const userSelector = (state: RootState) => state.userReducer;
export default userSlice.reducer;

In the above code, we’re using createAsyncThunk() — a function that takes in an action type string and a callback function that returns a promise. Once this promise is resolved, createAsyncThunk() automatically dispatches a pending action, followed by either a fulfilled or a rejected action, depending on whether the promise was resolved successfully or with an error.

Next, reducers need to handle the dispatched actions and update the store accordingly. One thing to point out here is a reducer defined by createSlice() can only handle the action types that were defined inside createSlice(). We use extraReducers to respond to such action types.

Using the addCase method of the builder object, extraReducers defines the actions that the users slice should take in response to the pending, fulfilled, and rejected actions that are triggered by the fetchUsers() async thunk.

Let’s connect our component:

// src/features/users/user.tsx
import { useEffect, useState } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { User, fetchUsers, userSelector } from "./userSlice";
import "./user.css";
function UserPage() {
  const [users, setUsers] = useState<Array<User>>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<string | undefined>(undefined);
  const selectedUsers = useAppSelector(userSelector);
  const dispatch = useAppDispatch();
  useEffect(() => {
    setLoading(selectedUsers.loading);
    setError(selectedUsers.error);
    setUsers(selectedUsers.users);
  }, [selectedUsers]);
  function handleFetchUser() {
    dispatch(fetchUsers());
  }
  return (
    <div>
      {loading && <div>Loading...</div>}
      {error && <div>Error: {error}</div>}
      {users?.map((user) => (
        <li key={user.id}>
          {user.id} | {user.name} | {user.email}
        </li>
      ))}
      <button className="btn" onClick={handleFetchUser}>Fetch</button>
    </div>
  );
}
export default UserPage;

In the above code, our thunk fetchUsers() is triggered when clicking the button to fetch the list of users from an endpoint and dispatch actions along the way. extraReducers updates the store as per the action types.

As the component is connected to the store, it is notified of changes in the store and that’s when the useEffect() Hook is triggered to update the component state and render accordingly:

A List Of Redux Users Fetched From An Endpoint

And here’s the detailed logging:

Detailed Logging Of Our Redux DevTools

Conclusion

TypeScript and Redux Toolkit make a great combination for developing efficient and reliable web applications. TypeScript helps us catch type-related errors during the development process, which can save a lot of time and effort in debugging.

Redux Toolkit, on the other hand, provides a simplified and opinionated approach to managing state with Redux. With its built-in support for features like Immer and async thunks, Redux Toolkit makes it easier to write and maintain Redux code.

Source: https://blog.logrocket.com

#typescript #redux

How to Set Up and Use TypeScript with Redux Toolkit
1.30 GEEK