Using Local Storage with Redux Toolkit

Paul Littlewood
15 Feb 2023
  • React

I’ve recently been working on an eCommerce frontend using React. Naturally I want the cart state to persist between page refreshes and I know using local storage is a good way to do this. I’ve successfully stored state in local storage before in previous projects but not with Redux which turns out to present an interesting challenge.

Starting Code

Here we have a simple cart setup using Redux Toolkit with reducers to add items, update item quantities, and remove items:

// store/index.js
import { configureStore } from '@reduxjs/toolkit';
import cartSlice from './cart-slice';

const store = configureStore({
  reducer: { cart: cartSlice.reducer }
});

export default store;
// store/cart-slice.js
import { createSlice } from '@reduxjs/toolkit';

const cartSlice = createSlice({
	name: 'cart',
	initialState: {
		items: [],
	},
	reducers: {
		addItem: (state, action) => {
			// Logic to add a new item
			return { items: updatedCartItems };
		},
		udateItemQty: (state, action) => {
			// Logic to update an Item's quantity
			return { items: updatedCartItems };
		},
		removeItem: (state, action) => {
			// Logic to remove an Item
			return { items: updatedCartItems };
		},
	},
});

export const { addItem, updateItemQty, removeItem } = cartSlice.actions;
export default cartSlice;

First Try

My instinctive reaction was to simply write the items array to local storage inside the reducer before returning and of course load the initialState from local storage if it existed – Easy!

// store/cart-slice.js
// Warning - This is bad practice!
import { createSlice } from '@reduxjs/toolkit';
const LOCAL_STORAGE_KEY = 'rowanReact.cartItems';

const cartSlice = createSlice({
	name: 'cart',
	initialState: {
		items: JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY)) ?? [],
	},
	reducers: {
		addItem: (state, action) => {
			// Logic to add a new item
			localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updatedCartItems));
			return { items: updatedCartItems };
		},
		// Etc...
	},
});

Great that worked, job done 💪. But wait, I think I remember something about Redux reducers should be pure functions and side-effect free. Oh no, my OCD won’t let this go we had better check this out.

Reducers Must Not Have Side Effects

From the Redux docs:

Also from the Redux Docs to add some context:

OK, well that seems pretty categorical. In this case the code does in fact work but I’d rather stick with best practices and learn how to do this properly which will serve us better in the future.

Let’s Have Another Go

A little more research threw up a number of potential solutions but the most promising and simple seems to be a new Redux Toolkit feature createListenerMiddleware.

Sounds perfect for our use case, it looks simple to use and nothing new to install. Lets give it a go!

// store/index.js

import { configureStore, createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit';
import cartSlice, { addItem, updateItemQty, removeItem, LOCAL_STORAGE_KEY } from './cart-slice';

const localStorageMiddleware = createListenerMiddleware();

localStorageMiddleware.startListening({
  matcher: isAnyOf(addItem, updateItemQty, removeItem),
  effect: (action, listenerApi) => localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(listenerApi.getState().cart.items)),
});

const store = configureStore({
  reducer: { cart: cartSlice.reducer },
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend(localStorageMiddleware.middleware),
});

export default store;

Great, it works perfectly 🙂. You can see the code implemented in the project here.