Writing Thunks using createAsyncThunk in React Redux

We can write thunks that dispatch “loading”, “request succeeded”, and “request failed” actions. We had to write action creators, action types, and reducers to handle those cases.

Because this pattern is so common, Redux Toolkit has a createAsyncThunk API that will generate these thunks for us. It also generates the action types and action creators for those different request status actions, and dispatches those actions automatically based on the resulting Promise.

booksSlices.ts

import {createAsyncThunk, createEntityAdapter, createSlice} from "@reduxjs/toolkit";
import {RootState} from "../../app/store";

export type BookState = { bookId: number, bookTitle: string, };

export const addOneBookAsync = createAsyncThunk(
    "books/addOneBookAsync",
    async (bookTitle: string) => {
        let result;
        setTimeout(result = function () {
            const newBook: BookState = {bookId: Math.floor(Math.random() * 1000), bookTitle: bookTitle};
            return newBook;
        }, 1000);
        return result();
    }
);

const booksAdapter = createEntityAdapter<BookState>({
    selectId: model => model.bookId,
    sortComparer: (a, b) => a.bookTitle.localeCompare(b.bookTitle),
});

export const booksSlice = createSlice({
    name: "books",
    initialState: booksAdapter.getInitialState,
    reducers: {
        addOne: booksAdapter.addOne,
    },
    extraReducers: builder => {
        builder
            .addCase(addOneBookAsync.pending, state => {
                // we can update state while loading data
                console.log("loading");
            })
            .addCase(addOneBookAsync.fulfilled, (state, action) => {
                // we can update state after successful response or we can call another reducer
                // here we are going to call another reducer
                booksSlice.caseReducers.addOne(state, action);
                console.log("successful");
            }).addCase(addOneBookAsync.rejected, state => {
            // we can update state after failed
            console.log("failed");
        })
    }
});

export const booksActions = booksSlice.actions;
export const booksSelectors = booksAdapter.getSelectors<RootState>(
    (state) => state.books
)

export default booksSlice.reducer;

Books.tsx

import {useAppSelector, useAppDispatch} from '../../app/hooks';
import {BookState, booksActions, booksSelectors, addOneBookAsync} from "./booksSlice";
import {useState} from "react";

function Books() {

    const dispatch = useAppDispatch();
    const books = useAppSelector(booksSelectors.selectAll);
    const [bookTitle, setBookTitle] = useState("");

    const addHandler = () => {
        const newBook: BookState = {
            bookId: Math.floor(Math.random() * 1000),
            bookTitle: bookTitle
        };
        dispatch(booksActions.addOne(newBook));
    }

    const addAsyncHandler = async () => {
        try {
            const result = await dispatch(addOneBookAsync(bookTitle)).unwrap();
            console.log(result);
        } catch (rejectedValueOrSerializedError) {
            // handle error here
        }
    }

    return (
        <div>

            <div className="mx-2 my-2">
                <input type="text" placeholder="Books Title" className="input input-bordered w-full max-w-xs"
                       value={bookTitle} onChange={event => setBookTitle(event.target.value)}/>
                <button className="btn mx-2" onClick={addHandler}>Add</button>
                <button className="btn mx-2" onClick={addAsyncHandler}>Add Async</button>
            </div>

            <ul>
                {books.map((value, index) => (
                    <li key={value.bookId}>Title : {value.bookTitle}, Id : {value.bookId}</li>
                ))}
            </ul>
        </div>
    );
}

export default Books;

Return Value

createAsyncThunk returns a standard Redux thunk action creator. The thunk action creator function will have plain action creators for the pendingfulfilled, and rejected cases attached as nested fields.

Handling Thunk Results​

The promise returned by the dispatched thunk has an unwrap property which can be called to extract the payload of a fulfilled action or to throw either the error or, if available, payload created by rejectWithValue from a rejected action:

const onClick = () => {
  dispatch(fetchUserById(userId))
    .unwrap()
    .then((originalPromiseResult) => {
      // handle result here
    })
    .catch((rejectedValueOrSerializedError) => {
      // handle error here
    })
}

Or with async/await syntax:

const onClick = async () => {
  try {
    const originalPromiseResult = await dispatch(fetchUserById(userId)).unwrap()
    // handle result here
  } catch (rejectedValueOrSerializedError) {
    // handle error here
  }
}

References
https://redux-toolkit.js.org/api/createAsyncThunk
https://redux.js.org/tutorials/fundamentals/part-8-modern-redux#writing-thunks
https://redux-toolkit.js.org/api/createslice#extrareducers
https://stackoverflow.com/questions/65106681/redux-toolkit-dispatch-an-action-from-an-extrareducers-listener

Normalizing State with createEntityAdapter in React Redux

We can read how to “normalize” state from here, but simply we can do it  by keeping items in an object keyed by item IDs. This gives us the ability to look up any item by its ID without having to loop through an entire array. However, writing the logic to update normalized state by hand was long and tedious. Writing “mutating” update code with Immer makes that simpler, but there’s still likely to be a lot of repetition – we might be loading many different types of items in our app, and we’d have to repeat the same reducer logic each time.

Redux Toolkit includes a createEntityAdapter API that has prebuilt reducers for typical data update operations with normalized state. This includes adding, updating, and removing items from a slice. createEntityAdapter also generates some memoized selectors for reading values from the store.

createEntityAdapter is a function that generates a set of prebuilt reducers and selectors for performing CRUD operations on a normalized state structure containing instances of a particular type of data object. These reducer functions may be passed as case reducers to createReducer and createSlice. They may also be used as “mutating” helper functions inside of createReducer and createSlice.

booksSlice.ts

import {createEntityAdapter, createSlice} from "@reduxjs/toolkit";
import {RootState} from "../../app/store";

export type BookState = { bookId: number, bookTitle: string, };

const booksAdapter = createEntityAdapter<BookState>({
    selectId: model => model.bookId,
    sortComparer: (a, b) => a.bookTitle.localeCompare(b.bookTitle),
});

export const booksSlice = createSlice({
    name: "books",
    initialState: booksAdapter.getInitialState,
    reducers: {
        addOne: booksAdapter.addOne,
    }
});

export const booksActions = booksSlice.actions;
export const booksSelectors = booksAdapter.getSelectors<RootState>(
    (state) => state.books
)

export default booksSlice.reducer;

Books.tsx

import {useAppSelector, useAppDispatch} from '../../app/hooks';
import {BookState, booksActions, booksSelectors} from "./booksSlice";
import {useState} from "react";

function Books() {

    const dispatch = useAppDispatch();
    const books = useAppSelector(booksSelectors.selectAll);
    const [bookTitle, setBookTitle] = useState("");

    const addHandler = () => {
        const newBook: BookState = {
            bookId: Math.floor(Math.random() * 1000),
            bookTitle: bookTitle
        };
        dispatch(booksActions.addOne(newBook));
    }

    return (
        <div>

            <div className="mx-2 my-2">
                <input type="text" placeholder="Books Title" className="input input-bordered w-full max-w-xs"
                       value={bookTitle} onChange={event => setBookTitle(event.target.value)}/>
                <button className="btn mx-2" onClick={addHandler}>Add</button>
            </div>

            <ul>
                {books.map((value, index) => (
                    <li key={value.bookId}>Title : {value.bookTitle}, Id : {value.bookId}</li>
                ))}
            </ul>
        </div>
    );
}

export default Books;

References
https://redux-toolkit.js.org/api/createEntityAdapter
https://redux.js.org/tutorials/fundamentals/part-8-modern-redux#normalizing-state
https://redux.js.org/usage/structuring-reducers/normalizing-state-shape

Implementing Counter Example in Typescript React using Redux Toolkit

store.ts

import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  unknown,
  Action<string>
>;

hooks.ts

import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

counterApi.ts

// A mock function to mimic making an async request for data
export function fetchCount(amount = 1) {
  return new Promise<{ data: number }>((resolve) =>
    setTimeout(() => resolve({ data: amount }), 500)
  );
}

counterSlice.ts

import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { RootState, AppThunk } from '../../app/store';
import { fetchCount } from './counterAPI';

export interface CounterState {
  value: number;
  status: 'idle' | 'loading' | 'failed';
}

const initialState: CounterState = {
  value: 0,
  status: 'idle',
};

// The function below is called a thunk and allows us to perform async logic. It
// can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This
// will call the thunk with the `dispatch` function as the first argument. Async
// code can then be executed and other actions can be dispatched. Thunks are
// typically used to make async requests.
export const incrementAsync = createAsyncThunk(
  'counter/fetchCount',
  async (amount: number) => {
    const response = await fetchCount(amount);
    // The value we return becomes the `fulfilled` action payload
    return response.data;
  }
);

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  // The `reducers` field lets us define reducers and generate associated actions
  reducers: {
    increment: (state) => {
      // Redux Toolkit allows us to write "mutating" logic in reducers. It
      // doesn't actually mutate the state because it uses the Immer library,
      // which detects changes to a "draft state" and produces a brand new
      // immutable state based off those changes
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    // Use the PayloadAction type to declare the contents of `action.payload`
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
  },
  // The `extraReducers` field lets the slice handle actions defined elsewhere,
  // including actions generated by createAsyncThunk or in other slices.
  extraReducers: (builder) => {
    builder
      .addCase(incrementAsync.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(incrementAsync.fulfilled, (state, action) => {
        state.status = 'idle';
        state.value += action.payload;
      })
      .addCase(incrementAsync.rejected, (state) => {
        state.status = 'failed';
      });
  },
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;

// The function below is called a selector and allows us to select a value from
// the state. Selectors can also be defined inline where they're used instead of
// in the slice file. For example: `useSelector((state: RootState) => state.counter.value)`
export const selectCount = (state: RootState) => state.counter.value;

// We can also write thunks by hand, which may contain both sync and async logic.
// Here's an example of conditionally dispatching actions based on current state.
export const incrementIfOdd =
  (amount: number): AppThunk =>
  (dispatch, getState) => {
    const currentValue = selectCount(getState());
    if (currentValue % 2 === 1) {
      dispatch(incrementByAmount(amount));
    }
  };

export default counterSlice.reducer;

Counter.tsx

import React, { useState } from 'react';

import { useAppSelector, useAppDispatch } from '../../app/hooks';
import {
  decrement,
  increment,
  incrementByAmount,
  incrementAsync,
  incrementIfOdd,
  selectCount,
} from './counterSlice';
import styles from './Counter.module.css';

export function Counter() {
  const count = useAppSelector(selectCount);
  const dispatch = useAppDispatch();
  const [incrementAmount, setIncrementAmount] = useState('2');

  const incrementValue = Number(incrementAmount) || 0;

  return (
    <div>
      <div className={styles.row}>
        <button
          className={styles.button}
          aria-label="Decrement value"
          onClick={() => dispatch(decrement())}
        >
          -
        </button>
        <span className={styles.value}>{count}</span>
        <button
          className={styles.button}
          aria-label="Increment value"
          onClick={() => dispatch(increment())}
        >
          +
        </button>
      </div>
      <div className={styles.row}>
        <input
          className={styles.textbox}
          aria-label="Set increment amount"
          value={incrementAmount}
          onChange={(e) => setIncrementAmount(e.target.value)}
        />
        <button
          className={styles.button}
          onClick={() => dispatch(incrementByAmount(incrementValue))}
        >
          Add Amount
        </button>
        <button
          className={styles.asyncButton}
          onClick={() => dispatch(incrementAsync(incrementValue))}
        >
          Add Async
        </button>
        <button
          className={styles.button}
          onClick={() => dispatch(incrementIfOdd(incrementValue))}
        >
          Add If Odd
        </button>
      </div>
    </div>
  );
}

 

Redux Fundamentals, Part 8: Modern Redux with Redux Toolkit

  • Redux Toolkit (RTK) is the standard way to write Redux logic
    • RTK includes APIs that simplify most Redux code
    • RTK wraps around the Redux core, and includes other useful packages
  • configureStore sets up a Redux store with good defaults
    • Automatically combines slice reducers to create the root reducer
    • Automatically sets up the Redux DevTools Extension and debugging middleware
  • createSlice simplifies writing Redux actions and reducers
    • Automatically generates action creators based on slice/reducer names
    • Reducers can “mutate” state inside createSlice using Immer
  • createAsyncThunk generates thunks for async calls
    • Automatically generates a thunk + pending/fulfilled/rejected action creators
    • Dispatching the thunk runs your payload creator and dispatches the actions
    • Thunk actions can be handled in createSlice.extraReducers
  • createEntityAdapter provides reducers + selectors for normalized state
    • Includes reducer functions for common tasks like adding/updating/removing items
    • Generates memoized selectors for selectAll and selectById

References
https://redux.js.org/tutorials/fundamentals/part-8-modern-redux

Redux Fundamentals, Part 7: Standard Redux Patterns

  • Action creator functions encapsulate preparing action objects and thunks
    • Action creators can accept arguments and contain setup logic, and return the final action object or thunk function
  • Memoized selectors help improve Redux app performance
    • Reselect has a createSelector API that generates memoized selectors
    • Memoized selectors return the same result reference if given the same inputs
  • Request status should be stored as an enum, not booleans
    • Using enums like 'idle' and 'loading' helps track status consistently
  • “Flux Standard Actions” are the common convention for organizing action objects
    • Actions use payload for data, meta for extra descriptions, and error for errors
  • Normalized state makes it easier to find items by ID
    • Normalized data is stored in objects instead of arrays, with item IDs as keys
  • Thunks can return promises from dispatch
    • Components can wait for async thunks to complete, then do more work

References
https://redux.js.org/tutorials/fundamentals/part-7-standard-patterns

Redux Fundamentals, Part 6: Async Logic and Data Fetching

  • Redux middleware were designed to enable writing logic that has side effects
    • “Side effects” are code that changes state/behavior outside a function, like AJAX calls, modifying function arguments, or generating random values
  • Middleware add an extra step to the standard Redux data flow
    • Middleware can intercept other values passed to dispatch
    • Middleware have access to dispatch and getState, so they can dispatch more actions as part of async logic
  • The Redux “Thunk” middleware lets us pass functions to dispatch
    • “Thunk” functions let us write async logic ahead of time, without knowing what Redux store is being used
    • A Redux thunk function receives dispatch and getState as arguments, and can dispatch actions like “this data was received from an API response”

References
https://redux.js.org/tutorials/fundamentals/part-6-async-logic

Redux Fundamentals, Part 5: UI and React

  • Redux stores can be used with any UI layer
    • UI code always subscribes to the store, gets the latest state, and redraws itself
  • React-Redux is the official Redux UI bindings library for React
    • React-Redux is installed as a separate react-redux package
  • The useSelector hook lets React components read data from the store
    • Selector functions take the entire store state as an argument, and return a value based on that state
    • useSelector calls its selector function and returns the result from the selector
    • useSelector subscribes to the store, and re-runs the selector each time an action is dispatched.
    • Whenever the selector result changes, useSelector forces the component to re-render with the new data
  • The useDispatch hook lets React components dispatch actions to the store
    • useDispatch returns the actual store.dispatch function
    • You can call dispatch(action) as needed inside your components
  • The <Provider> component makes the store available to other React components
    • Render <Provider store={store}> around your entire <App>

References
https://redux.js.org/tutorials/fundamentals/part-5-ui-react

Redux Fundamentals, Part 4: Store

  • Redux apps always have a single store
    • Stores are created with the Redux createStore API
    • Every store has a single root reducer function
  • Stores have three main methods
    • getState returns the current state
    • dispatch sends an action to the reducer to update the state
    • subscribe takes a listener callback that runs each time an action is dispatched
  • Store enhancers let us customize the store when it’s created
    • Enhancers wrap the store and can override its methods
    • createStore accepts one enhancer as an argument
    • Multiple enhancers can be merged together using the compose API
  • Middleware are the main way to customize the store
    • Middleware are added using the applyMiddleware enhancer
    • Middleware are written as three nested functions inside each other
    • Middleware run each time an action is dispatched
    • Middleware can have side effects inside
  • The Redux DevTools let you see what’s changed in your app over time
    • The DevTools Extension can be installed in your browser
    • The store needs the DevTools enhancer added, using composeWithDevTools
    • The DevTools show dispatched actions and changes in state over time

References
https://redux.js.org/tutorials/fundamentals/part-4-store

Redux Fundamentals, Part 3: State, Actions, and Reducers

  • Redux apps use plain JS objects, arrays, and primitives as the state values
    • The root state value should be a plain JS object
    • The state should contain the smallest amount of data needed to make the app work
    • Classes, Promises, functions, and other non-plain values should not go in the Redux state
    • Reducers must not create random values like Math.random() or Date.now()
    • It’s okay to have other state values that are not in the Redux store (like local component state) side-by side with Redux
  • Actions are plain objects with a type field that describe what happened
    • The type field should be a readable string, and is usually written as 'feature/eventName'
    • Actions may contain other values, which are typically stored in the action.payload field
    • Actions should have the smallest amount of data needed to describe what happened
  • Reducers are functions that look like (state, action) => newState
    • Reducers must always follow special rules:
      • Only calculate the new state based on the state and action arguments
      • Never mutate the existing state – always return a copy
      • No “side effects” like AJAX calls or async logic
  • Reducers should be split up to make them easier to read
    • Reducers are usually split based on top-level state keys or “slices” of state
    • Reducers are usually written in “slice” files, organized into “feature” folders
    • Reducers can be combined together with the Redux combineReducers function
    • The key names given to combineReducers define the top-level state object keys

References
https://redux.js.org/tutorials/fundamentals/part-3-state-actions-reducers