Skip to main content

A powerful React + Redux Toolkit pattern (reuseable state slices)

If you need global state management for your app(s) (and you probably do), a relatively new flavour of Redux called Redux Toolkit makes this considerably easier. Redux Toolkit includes a number of functions which make reusing similar "slices" of state quite easy, however there is a missing piece to this puzzle that will make things seamless!

by jack.taranto /

This article assumes an understanding of (on the React side) Context, hooks, components, (and on the Redux side) actions & reducers, Redux hooks, and at least a little dabble in Redux Toolkit.

The problem

Whilst developing a recent project which utilises an Elasticsearch backend to provide a number of independent search interfaces, a need came up to have very similar state management for each application. In Redux there can only be one store, and we put this to the test with an initial prototype that threw caution to the wind and used a separate store for each application. The choice of using separate stores allowed us to easily reuse React components that implemented Redux's useSelector hook to retrieve data from the store.

However, it quickly became apparent that Redux had not been designed to work this way. Redux's excellent dev tools stopped functioning as intended, and the dev tools being a big driving point behind using Redux in the first place - meant we had to rethink our implementation.

The initial build featured a suite of reusable components that each implement useDispatch and useSelector to act on their independent state management. Take a Pagination component for example. It needs to keep track of which page a user is currently on, as well as update the current page. A useSelector hook allows it to retrieve the current page, and a useDispatch hook allows it to call an action which updates the current page.

Our architecture has a top level App component which is unique for each search application. It acts as a container and wraps the layout and components. In our initial prototype it implemented it's own store and Redux's Provider component so the child components could implement Redux hooks directly.

So with a shared store, this architecture was at risk of becoming very complex. The App wrapper would need to implement the hooks and actions itself, and then pass them down via Context or props so child components could access the correct data and actions in the store.

Instead of doing this we came up with something new.

The architecture

The updated architecture uses a React Context Provider to pass the applications "slice" down via Context. Then a custom useContext hook allows each reusable component to access store data and actions for that slice, without needing to be aware of which slice they are part of.

Let's look at it from the top.

Redux multi slice architecture

A single global store is broken up into slices using the Redux Toolkit createSlice function. Each slice has its own name, initial state, reducers and actions.

Each application has it's own container which implements a Context provider called SliceProvider, which imports the slice and allows that to be accessed across the application.

All child components can then be shared and access the state slice via a series of custom hooks that implement useContext.

When you view things through Redux Dev tools, this is what it looks like:

Redux dev tools showing multi slice store

Demonstration

To take a look at things I've spun up an example application, with two search interfaces - a "global" search and an "image" search.

The index.js looks like this, All it's doing is wrapping everything in the Redux `Provider`:

import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store/store';
import App from './components/App';

const container = document.getElementById('root');
const root = createRoot(container);

root.render(
  <Provider store={store}>
    <App />
  </Provider>
);

And our App.js combines both applications purely for demonstration purposes so we can see them working on screen in tandem:

import Pager from './Pager';
import SliceProvider from './SliceProvider';
import { globalSearchSlice, imageSearchSlice } from '../store/slices';

const App = () => (
  <>
    <header>
      <h1>Search</h1>
    </header>
    <main>
      <SliceProvider slice={globalSearchSlice}>
        <h2>Global</h2>
        <Pager />
      </SliceProvider>
      <SliceProvider slice={imageSearchSlice}>
        <h2>Images</h2>
        <Pager />
      </SliceProvider>
    </main>
  </>
);

export default App;

Now components under each SliceProvider will have access to their slice of the shared store.

Reusable reducers

With this pattern, reducers are reusable chunks of logic. We'll maintain and export them from a reducers.js file:

export const updateCurrentPage = (state, action) => ({
  ...state,
  currentPage: action.payload,
})

export const updateResultsPerPage = (state, action) => ({
  ...state,
  resultsPerPage: action.payload,
})

If you haven't used Redux Toolkit yet, this simplified syntax for reducers makes it a clear winner. Also there is no need to use the Redux Toolkit createReducer function, as that is called below with createSlice.

Creating slices

Let's take a look at slices.js which creates each slice via a function which lets us:

  1. Define a default "initialState" for each slice.
  2. Define a set of default reducers for each slice.
  3. Takes an argument for initialState to override certain values.
  4. Takes an argument for reducers so we can add to the default reducers - if one application has more advanced features for example.
  5. Includes a way to pass through extraReducers (more on this later).
import { createSlice } from "@reduxjs/toolkit"
import {
  updateCurrentPage,
  updateResultsPerPage,
} from "./reducers";

const createSearchAppSlice = ({ name, initialState, reducers, extraReducers }) => {
  // Setup a default state, and allow the function arg to change the defaults. 
  initialState = {
    currentPage: 1,
    resultsPerPage: 10,
    ...initialState
  }
  return createSlice({
    name,
    initialState,
    reducers: {
      // The first reducer is a utility to reset the state.
      reset: () => ({ ...initialState }),
      // Our reusable reducers will go here...
      updateCurrentPage,
      updateResultsPerPage,
      // Then pass through any other reducers specific to this slice only.
      ...reducers,
    },
    extraReducers: {
      // extraReducers are global reducers that apply to all slices.
      // We'll come back to these later.
      ...extraReducers,
    },
  })
}

// The global slice uses the default function.
export const globalSearchSlice = createSearchAppSlice({
  name: "globalSearch",
})

// The image slice changes some of the defaults.
export const imageSearchSlice = createSearchAppSlice({
  name: "imageSearch",
  initialState: {
    resultsPerPage: 20,
  }
})

This approach makes slice creation reusable but extensible.

The store

Our store.js makes use of combineReducers to add our slices.

import { combineReducers, configureStore } from "@reduxjs/toolkit"
import { globalSearchSlice, imageSearchSlice } from "./slices.js"

const reducers = combineReducers({
  [globalSearchSlice.name]: globalSearchSlice.reducer,
  [imageSearchSlice.name]: imageSearchSlice.reducer,
})

export const store = configureStore({
  reducer: reducers,
  devTools: process.env.NODE_ENV !== "production",
})

Secret magic sauce

Now, to the secret magic sauce of this pattern - the SliceProvider context:

import { createContext, useContext } from "react"
import { useSelector } from 'react-redux';

const SliceContext = createContext({})

const SliceProvider = ({ slice, children }) => (
  <SliceContext.Provider value={slice}>{children}</SliceContext.Provider>
)

const useSliceActions = () => useContext(SliceContext).actions

const useSliceSelector = () => {
  const { name } = useContext(SliceContext)
  return useSelector(state => {
    return state[name]
  })
}

export default SliceProvider
export { useSliceActions, useSliceSelector }

And because our components are wrapped in a SliceProvider, the useSliceActions hook allows access to the actions and the useSliceSelector hook allows access to the slice store data (via the Redux useSelector hook).

A child component can then be implemented as so:

import { useDispatch } from "react-redux";
import { useSliceActions, useSliceSelector } from "./SliceProvider";

const Pager = () => {
  const dispatch = useDispatch();
  const { currentPage } = useSliceSelector();
  const { updateCurrentPage } = useSliceActions();
  return (
    <div>
      {currentPage}
      <button onClick={() => dispatch(updateCurrentPage(currentPage + 1))}>
        Next page
      </button>
    </div>
  );
};

export default Pager;

Now both applications can share child components that provide totally unique sets of data and actions from each slice!

Redux dev tools showing actions called on slices

Using extraReducers

Now let's take things a step further and come back to the extraReducers piece I touched on above. In Redux Toolkit extraReducers are a way to listen to actions across all slices - meaning we can call an action in one place and have it update data everywhere!

To do this we need to define some actions in actions.js:

import { createAction } from "@reduxjs/toolkit"

export const updateAllCurrentPage = createAction("updateCurrentPage")

We must also write a new reducer which is suitable to be used across the whole store in reducers.js:

export const updateAllCurrentPage = (state) => ({
  ...state,
  currentPage: state.currentPage + 1
});

Now we can import the action and reducer and use them in slices.js (I love this syntax in Redux Toolkit):

import {
  updateAllCurrentPage as updateAllCurrentPageReducer,
} from "./reducers";
import { updateAllCurrentPage } from "./actions";

export const globalSearchSlice = createSearchAppSlice({
  name: "globalSearch",
  extraReducers: {
    [updateAllCurrentPage]: updateAllCurrentPageReducer,
  },
});

export const imageSearchSlice = createSearchAppSlice({
  name: "imageSearch",
  initialState: {
    resultsPerPage: 20,
  },
  extraReducers: {
    [updateAllCurrentPage]: updateAllCurrentPageReducer,
  },
});

A new component can import the action directly and call it via useDispatch:

import { useDispatch } from "react-redux";
import { updateAllCurrentPage } from "../store/actions";

const AllPager = () => {
  const dispatch = useDispatch();
  return (
    <button onClick={() => dispatch(updateAllCurrentPage())}>
      Next page(s)
    </button>
  );
};

export default AllPager;
Redux dev tools showing extra reducers being called on all slices

Summation

This pattern can become very powerful for the following reasons:

  1. It utilises all features of Redux Toolkit to provide reusable slices, actions, and reducers.
  2. Each slice is completely independent, however it can share capabilities as needed.
  3. Any state aware component can access a slice directly and be reused across slices.
  4. Components can still update state across the whole store as needed.

Take a look a the example application.

Posted by jack.taranto
Front end developer

Dated