Miłosz Orzeł

.net, js, html, arduino, java... no rants or clickbaits.

RTK Slice Reducers Performance

INTRO

Previous two posts (1, 2) were about rendering performance, now it's time for something from the back-of-the-fronted: state management. More specifically: checking the speed at which Redux Toolkit can create slices/reducers and handle dispatched actions. The RTK addressed Redux's primary criticism: amount of boilerplate. Now (read: for many years) a couple of lines of code is enough to get fully functioning store setup with dev tools enabled, reducers with Immer, actions (creators), thunks etc. All with great TypeScript support... Plenty of nice features, but how about the speed

TL;DR: The overhead of building a store and reducers with RTK should not significantly impact application performance in real-life scenarios.

BTW: Check RTK Query for fetching & caching!

 

THE TEST APP

The app was made with Vite Vanilla + TypeScript template (vite: 5.4.10, typescript: 5.6.2) with added Redux Toolkit (@reduxjs/toolkit: 2.3.0).

Live demo is here: https://morzel85.github.io/blog-post-rtk-slice-reducers-performance

The code is here: https://github.com/morzel85/blog-post-rtk-slice-reducers-performance

Performance test app... Click to enlarge...

When "Setup store ..." button is clicked, the application uses createSlice function to make slice reducers with requested amount of actions. Each reducer manages a state that contains numerical properties. The amount of properties matches the amount of actions. Each generated action is designed to control single state property. The count of slices and actions per each slice is controlled by SLICES_COUNT and ACTIONS_PER_SLICE_COUNT constants. On the published version of the app these are set to 500 and 25 respectively, which gives a total of 12.5K actions. Once the slices are defined, the store is created with a call to configureStore and a subscription to store changes is added. When "Dispatch random change" button is pressed, a single action is dispatched to modify single value in one of the state slices...

The 500 * 25 setup is quite big, so let's see a simpler example and assume that we want to have 2 slices with 3 actions in each. In that case, we would have such initial state in store:

{
  slice_0: {
    value_0: 0,
    value_1: 0
  },
  slice_1: {
    value_0: 0,
    value_1: 0
  }
}

Now, let's assume that after clicking "Dispatch random change" button, such action was fired:

{
  type: 'slice_0/changed_0',
  payload: 0.715990726007008
}

After it's handled, the state should look like this:

{
  slice_0: {
    value_0: 0.715990726007008,
    value_1: 0
  },
  slice_1: {
    value_0: 0,
    value_1: 0
  }
}

Here's the code (crammed into main.ts):

import { configureStore, createSlice } from '@reduxjs/toolkit';
import type { EnhancedStore, PayloadAction, ReducersMapObject } from '@reduxjs/toolkit';
import './style.css';

export const SLICES_COUNT = 500;
export const ACTIONS_PER_SLICE_COUNT = 25;

type SliceState = Record<string, number>;
type SliceAction = PayloadAction<number>;
type SliceReducer = Record<string, (state: SliceState, action: SliceAction) => void>;

const setupStore = () => {
  const slices = [];
  let createSliceDurationSum = 0;

  for (let s = 0; s < SLICES_COUNT; s++) {
    const initialState: SliceState = {};
    const reducers: SliceReducer = {};

    // Each slice state should have value_* properties and a matching amount
    // of changed_* actions that modify corresponding value in the state.
    for (let a = 0; a < ACTIONS_PER_SLICE_COUNT; a++) {
      initialState[`value_${a}`] = 0;
      reducers[`changed_${a}`] = (state: SliceState, action: SliceAction) => {
        state[`value_${a}`] = action.payload;
      };
    }

    // Creating slice (RTK API)
    const start = performance.now();
    const slice = createSlice({
      name: `slice_${s}`,
      initialState,
      reducers
    });
    const end = performance.now();
    createSliceDurationSum += end - start;

    slices.push(slice);
  }

  console.log(
    performance.measure('create-slices', {
      start: 0,
      duration: createSliceDurationSum
    })
  );

  // Collecting slice reducers config
  const reducer = slices.reduce((acc, { name, reducer }) => {
    acc[name] = reducer;
    return acc;
  }, {} as ReducersMapObject);

  // Configuring store (RTK API)
  performance.mark('configure-store-start');
  const store = configureStore({
    reducer
  });
  performance.mark('configure-store-end');
  console.log(
    performance.measure('configure-store', {
      start: 'configure-store-start',
      end: 'configure-store-end',
      detail: {
        slices: SLICES_COUNT,
        actionsPerSlice: ACTIONS_PER_SLICE_COUNT,
        actionsTotal: SLICES_COUNT * ACTIONS_PER_SLICE_COUNT,
        statePropertiesTotal: SLICES_COUNT + SLICES_COUNT * ACTIONS_PER_SLICE_COUNT
      }
    })
  );

  // Listening for changes in store (Redux API)
  store.subscribe(() => {
    performance.mark('action-end');
    console.log(performance.measure('action', 'action-start', 'action-end'));
  });

  return store;
};

let store: EnhancedStore | undefined;

// Buttons handling
const setupStoreButton = document.getElementById('setupStore') as HTMLButtonElement;
const randomChangeButton = document.getElementById('randomChange') as HTMLButtonElement;

setupStoreButton.textContent += ` (slices: ${SLICES_COUNT}, actions per slice: ${ACTIONS_PER_SLICE_COUNT})`;

setupStoreButton.addEventListener('click', () => {
  store = setupStore();
  setupStoreButton.disabled = true;
  randomChangeButton.disabled = false;
});

randomChangeButton.addEventListener('click', () => {
  if (store) {
    const sliceName = `slice_${Math.floor(Math.random() * SLICES_COUNT)}`;
    const actionName = `changed_${Math.floor(Math.random() * ACTIONS_PER_SLICE_COUNT)}`;
    const action = {
      type: `${sliceName}/${actionName}`,
      payload: Math.random()
    };

    // Dispatching action (Redux API)
    performance.mark('action-start');
    store.dispatch(action);
  }
});

The code uses Performance API, and logs measurements to the console.

THE RESULTS

The test were run on 7 years old PC with Intel Core i7-7700K and 16 GB RAM with Chrome 130 on Ubuntu 20.04. 

Each test consisted of creating a store and dispatching 5 times (to get average time for action, as these can vary).
I've run each SLICES_COUNT * ACTIONS_PER_SLICE_COUNT test case 3 times and reported the average duration:

  • 100 slices * 10 actions (total actions: 1K, state size: 1.1K properties): 
    create-slices: 15msconfigure-store: 16ms, action: 1ms
  • 50 slices * 50 actions (total actions: 2.5K, state size: 2.55K properties): 
    create-slices: 21msconfigure-store: 17ms, action: 1ms
  • 100 slices * 100 actions (total actions: 10K, state size: 10.1K properties): 
    create-slices: 38msconfigure-store: 18ms, action: 1.3ms
  • 500 slices * 25 actions (total actions: 12.5K, state size: 13K properties): 
    create-slices: 45msconfigure-store: 17ms, action: 2.6ms
  • 500 slices * 500 actions (total actions: 250K, state size: 250.5K properties): 
    create-slices: 320msconfigure-store: 530ms, action: 6ms
  • 1000 slices * 10 actions (total actions: 10K, state size: 11K properties): 
    create-slices: 46msconfigure-store: 17ms, action: 3ms
  • 1000 slices * 100 actions (total actions: 100K, state size: 101K properties): 
    create-slices: 140msconfigure-store: 120ms, action: 4ms
  • 1000 slices * 1000 actions (total actions: 1M - yeah, state size: 1.001M properties): 
    create-slices: 900msconfigure-store: 3320ms, action: 10ms

This is what I got, get the app and test on your machine with various SLICES_COUNT and ACTIONS_PER_SLICE_COUNT values. Just remember to run in release mode because development has safety features enabled and you are likely to see SerializableStateInvariantMiddleware and ImmutableStateInvariantMiddleware warnings for very large stores. For large test cases, and even in release mode, you can expect to see "[Violation] 'click' handler took <number>ms" when the "Setup store ..." button is clicked (the entire setup phase is done synchronously in button event handler to keep things simple).

You got to admit that these test cases start quite big (1K actions), I bet most apps don't need that much. The later test cases are just for fun. If your web app really needs a million unique actions then for sure you got bigger problems than state management :)

Would you like to test some more? Consider adding cases for various store structures (nesting, arrays) and features like slice selectors or extra reducers... GL&HF!

P.S. If tormenting RTK with thousands of actions is not your thing, maybe you will like this (slightly more pragmatic) post: https://en.morzel.net/post/accessing-state-in-redux-thunks

Accessing State in Redux Thunks

INTRO

The Redux Thunks offer a good way of separating logic from UI components. Within a thunk, you can: access the entire state, dispatch actions and thunks, generate side effects (contrary to reducers)... Redux Toolkit contains the redux-thunk middleware and adds it during standard store configuration, nice.

While dispatching actions and accessing state in thunks is easy (every thunk receives dispatch and getState functions as arguments), it's also easy to misuse the API and operate on outdated state, especially when async operations are involved.

In this post I'll show you two possible mistakes, one about referencing stale state object and another related to order of async operations.

 

CODE SAMPLE

Full code is available in this GitHub repository (vite 5.2.0, react 18.2.0, react-redux 9.1.0, @reduxjs/toolkit 2.2.3, typescript 5.2.2): https://github.com/morzel85/blog-post-redux-thunk-state-access

Here's the Redux Toolkit slice that I'll use to discuss the issues (with the thunks skipped as these will be shown later).

import type { PayloadAction } from '@reduxjs/toolkit';
import type { AppThunk } from './store';
import { createSlice } from '@reduxjs/toolkit';

export type ExampleState = {
  count: number;
  text: string;
};

const initialState: ExampleState = {
  count: 0,
  text: ''
};

// Thunks skipped...

export const exampleSlice = createSlice({
  name: 'example',
  initialState,
  reducers: {
    resetCount: state => {
      console.log('Reducer resetting count');
      state.count = 0;
    },
    resetText: state => {
      console.log('Reducer resetting text');
      state.text = '';
    },
    increment: state => {
      console.log(`Reducer incrementing count ${state.count}`);
      state.count += 1;
    },
    appendText: (state, action: PayloadAction<string>) => {
      console.log(`Reducer appending text ${action.payload}`);
      state.text += action.payload;
    }
  }
});

export const { resetCount, resetText, increment, appendText } = exampleSlice.actions;

export default exampleSlice.reducer;

As you can see, the slice is really simple, it manages one number and one string.

 

STALE STATE

The second argument passed to a thunk is getState function. It gives you access to the entire state which you can use directly or through selectors...

In case you need to access the state multiple times, it's tempting to grab the state with single getState call, but don't do it. If you dispatch an action in a thunk, Redux will run the reducers and ensure that updated state will be available further in the thunk code, but to get it you must invoke the getState function again.

Take a look at this thunk:

export const thunkWithStateAccess = (): AppThunk => (dispatch, getState) => {
  console.log('thunkWithStateAccess START');
  dispatch(resetCount());

  // This value will be stale!
  const state = getState();

  console.log(`state.example === getState().example: ${state.example === getState().example}`); // true

  dispatch(increment());
  console.log(`count in thunk with state: ${state.example.count}`); // 0
  console.log(`count in thunk getState(): ${getState().example.count}`); // 1

  console.log(`state.example === getState().example: ${state.example === getState().example}`); // false

  dispatch(increment());
  console.log(`count in thunk with state: ${state.example.count}`); // 0
  console.log(`count in thunk getState(): ${getState().example.count}`); // 2

  console.log('thunkWithStateAccess END');
};

This is the console output:

thunkWithStateAccess START
Reducer resetting count
state.example === getState().example: true
Reducer incrementing count 0
count in thunk with state: 0
count in thunk getState(): 1
state.example === getState().example: false
Reducer incrementing count 1
count in thunk with state: 0
count in thunk getState(): 2
thunkWithStateAccess END

Notice that initially the state.example and getState().example reference the same object. This changes after increment action is dispatched. Reducer modifies the state and the latest value is no longer available under the state const (it doesn't matter that it's declared as const, same would happen with let).

 

ASYNC THUNKS

Now let's see what the state access situation looks like when async thunks are involved (here I'm talking about asynchronous thunks in general sense, not about the createAsyncThunk from RTK):

export const thunkThatAppendsB = (): AppThunk => dispatch => {
  dispatch(appendText('B'));
};

export const asyncThunkThatAppendsCWithDelay = (): AppThunk => async dispatch => {
  const value = await new Promise<string>(resolve => setTimeout(resolve, 1000, 'C'));
  dispatch(appendText(value));
};

export const asyncThunkThatAppendsDWithoutDelay = (): AppThunk => async dispatch => {
  const value = await Promise.resolve('D');
  dispatch(appendText(value));
};

export const thunkWithoutAwait = (): AppThunk => async dispatch => {
  console.log('thunkWithoutAwait START');
  dispatch(resetText());

  dispatch(appendText('A'));
  dispatch(thunkThatAppendsB());
  dispatch(asyncThunkThatAppendsCWithDelay()); // No await: E and D will be first!
  dispatch(asyncThunkThatAppendsDWithoutDelay()); // No await: E will be first!
  dispatch(appendText('E'));

  console.log('thunkWithoutAwait END'); // This will happen before D and C are appended!
};

This is the console output of thunkWithoutAwait:

thunkWithoutAwait START
Reducer resetting text
Reducer appending text A
Reducer appending text B
Reducer appending text E
thunkWithoutAwait END
Reducer appending text D
Reducer appending text C

The text state ends up as: "ABEDC", what a mess! 

Here the letter A is appended simply by dispatching appendText action. The letter B is added by dispatching synchronous thunk, that internally dispatches appendText action. So far so good, state contains the "AB" text. 

Problems start when asynchronous thunks are dispatched without await. Notice that appending happens right after B and that "thunkWithoutAwait END" is logged before the async thunks can modify the state. This happens even for the async thunk which resolves without delay.

The important thing is that both the asyncThunkThatAppendsCWithDelay and the asyncThunkThatAppendsDWithoutDelay are awaiting promise resolution, and this is enough for the browser to let the invoking function (thunkWithoutAwait) continue. It has nothing to do with Redux, this is how JavaScript promises work (even if you were to switch from await to classic then the effect would be the same).

Fortunately the solution is simple: don't forget the await if you expect serialized behavior of your thunks:

export const thunkWithAwait = (): AppThunk => async dispatch => {
  console.log('thunkWithAwait START');
  dispatch(resetText());

  dispatch(appendText('A'));
  dispatch(thunkThatAppendsB());
  await dispatch(asyncThunkThatAppendsCWithDelay());
  await dispatch(asyncThunkThatAppendsDWithoutDelay());
  dispatch(appendText('E'));

  console.log('thunkWithAwait END');
};

Now the console output is:

thunkWithAwait START
Reducer resetting text
Reducer appending text A
Reducer appending text B
Reducer appending text C
Reducer appending text D
Reducer appending text E
thunkWithAwait END

and the text state ends up as sane: "ABCDE".

That's it, just don't get too crazy and remember that thunks are not meant to dispatch tens of actions or handle persistent connections...