Author:
Source
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!
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.
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:
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(
);
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 = () => (
Search
Global
Images
>
);
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:
- Define a default “initialState” for each slice.
- Define a set of default reducers for each slice.
- Takes an argument for initialState to override certain values.
- Takes an argument for reducers so we can add to the default reducers – if one application has more advanced features for example.
- 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 }) => (
{children}
)
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 (
{currentPage}
);
};
export default Pager;
Now both applications can share child components that provide totally unique sets of data and actions from each slice!
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 (
);
};
export default AllPager;
Summation
This pattern can become very powerful for the following reasons:
- It utilises all features of Redux Toolkit to provide reusable slices, actions, and reducers.
- Each slice is completely independent, however it can share capabilities as needed.
- Any state aware component can access a slice directly and be reused across slices.
- Components can still update state across the whole store as needed.