Asia/Karachi
BlogOctober 4, 2026

Build a Shopping Cart with Redux Toolkit

A cart slice, derived totals and safe localStorage persistence
Fawaz Israr
Build a Shopping Cart with Redux Toolkit
A Redux Toolkit shopping cart is one slice that holds an array of items, a few reducers (addItem, removeItem, setQuantity, clear) and selectors that calculate the totals when you need them. Components dispatch actions to change the cart and read it with useSelector, so the product list, the header badge and the checkout page all stay in sync. Below is a complete setup in TypeScript with Redux Toolkit 2.x. My-Shop, the custom gift builder store I built, uses Redux for its state, and this is the shape I reach for when I build a cart. A cart is read in several places that are far apart in the component tree: a product card has an "add to cart" button, the navbar shows an item count, and the checkout page lists everything. You can lift state up or use context, but the cart also changes often and many components care about small slices of it. Redux gives you one store, predictable updates through actions, and selectors so each component re-renders only when the data it reads changes. Redux Toolkit removes most of the old boilerplate, which is why it is the recommended way to write Redux today.
Bash
npm install @reduxjs/toolkit react-redux
Ts
// store.ts
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "./cartSlice";

export const makeStore = () =>
  configureStore({
    reducer: { cart: cartReducer },
  });

export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore["getState"]>;
export type AppDispatch = AppStore["dispatch"];

// A client-only app (Vite, for example) can use one shared store.
export const store = makeStore();
configureStore wires up the Redux DevTools and sensible middleware by default. The makeStore function matters later, in the Next.js section. For a client-only app, wrap it once with the provider:
Tsx
// main.tsx
import { Provider } from "react-redux";
import { store } from "./store";

root.render(
  <Provider store={store}>
    <App />
  </Provider>
);
Each cart line stores the product id, the name, the unit price as an integer number of cents, and a quantity.
Ts
// cartSlice.ts
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";

export interface CartItem {
  id: string;
  name: string;
  priceInCents: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
}

const initialState: CartState = { items: [] };

const cartSlice = createSlice({
  name: "cart",
  initialState,
  reducers: {
    addItem(state, action: PayloadAction<Omit<CartItem, "quantity">>) {
      const existing = state.items.find((i) => i.id === action.payload.id);
      if (existing) {
        existing.quantity += 1;
      } else {
        state.items.push({ ...action.payload, quantity: 1 });
      }
    },
    removeItem(state, action: PayloadAction<string>) {
      state.items = state.items.filter((i) => i.id !== action.payload);
    },
    setQuantity(state, action: PayloadAction<{ id: string; quantity: number }>) {
      const item = state.items.find((i) => i.id === action.payload.id);
      if (!item) return;
      if (action.payload.quantity <= 0) {
        state.items = state.items.filter((i) => i.id !== item.id);
      } else {
        item.quantity = action.payload.quantity;
      }
    },
    clear(state) {
      state.items = [];
    },
  },
});

export const { addItem, removeItem, setQuantity, clear } = cartSlice.actions;
export default cartSlice.reducer;
Inside createSlice you can write existing.quantity += 1 and state.items.push(...). That looks like mutation, but Redux Toolkit runs reducers through Immer, which records your changes and produces a new immutable state. Mutating like this works only inside a reducer. Anywhere else in your code, treat state as read-only. Money is kept in cents so addition stays exact. Floating point prices such as 19.99 will eventually give you totals like 59.97000000000001.
Close-up of colourful HTML and SVG code on a screen
No. The total, the item count and any subtotal can all be calculated from items. If you store them as well, you now have two sources of truth, and every reducer has to remember to update both. One missed line and the badge disagrees with the checkout page. Put the calculations in selectors instead:
Ts
// cartSelectors.ts
import { createSelector } from "@reduxjs/toolkit";
import type { RootState } from "./store";

const selectItems = (state: RootState) => state.cart.items;

export const selectCartCount = createSelector([selectItems], (items) =>
  items.reduce((sum, i) => sum + i.quantity, 0)
);

export const selectSubtotalInCents = createSelector([selectItems], (items) =>
  items.reduce((sum, i) => sum + i.priceInCents * i.quantity, 0)
);
createSelector memoizes the result, so the reduce only runs again when items changes. The createSelector documentation explains how the input selectors work. The cart page is a component that reads items and dispatches setQuantity and removeItem. Because the reducers handle the zero case, the quantity input does not need any special logic.
Tsx
function CartPage() {
  const items = useAppSelector((state) => state.cart.items);
  const subtotal = useAppSelector(selectSubtotalInCents);
  const dispatch = useAppDispatch();

  return (
    <section>
      {items.map((item) => (
        <div key={item.id}>
          <span>{item.name}</span>
          <input
            type="number"
            min={0}
            value={item.quantity}
            onChange={(e) =>
              dispatch(setQuantity({ id: item.id, quantity: Number(e.target.value) }))
            }
          />
          <button onClick={() => dispatch(removeItem(item.id))}>Remove</button>
        </div>
      ))}
      <p>Subtotal: ${(subtotal / 100).toFixed(2)}</p>
    </section>
  );
}
Dividing by 100 happens only when you display the number. Everything inside the store and the selectors stays in cents. Tax, shipping and discounts do not belong in this subtotal selector yet, because the final numbers have to come from the server (more on that at the end). Create pre-typed versions of the hooks once, and import those everywhere instead of the plain ones.
Ts
// hooks.ts
import { useDispatch, useSelector } from "react-redux";
import type { AppDispatch, RootState } from "./store";

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
The .withTypes form is the recommended pattern from react-redux 9.1 onward. A component then looks like this:
Tsx
function CartBadge() {
  const count = useAppSelector(selectCartCount);
  return <span>{count}</span>;
}

function AddButton({ product }: { product: Omit<CartItem, "quantity"> }) {
  const dispatch = useAppDispatch();
  return <button onClick={() => dispatch(addItem(product))}>Add to cart</button>;
}
The Redux Toolkit TypeScript guide covers the other typing patterns if you need them. The straightforward approach is to read the saved cart when the store is created and write it back whenever the store changes. Wrap both in try/catch, because localStorage can throw in private windows or when storage is full, and the saved JSON may have been edited or come from an older version of your app.
Ts
// storage.ts
import type { CartItem } from "./cartSlice";

const KEY = "cart";

export function loadCart(): CartItem[] {
  try {
    const raw = localStorage.getItem(KEY);
    const parsed = raw ? JSON.parse(raw) : [];
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
}

export function saveCart(items: CartItem[]) {
  try {
    localStorage.setItem(KEY, JSON.stringify(items));
  } catch {
    // storage unavailable, the cart just will not persist
  }
}
Then call saveCart(store.getState().cart.items) inside store.subscribe and pass loadCart() as the preloaded cart state. For a client-only React app (Vite, for example) that is the whole job. In the Next.js App Router, components run on the server first, and localStorage does not exist there. Calling loadCart() during the initial render also gives the client a different cart from the one the server rendered, so React logs a hydration mismatch. A safe pattern is to start with an empty cart on both server and client, then load the saved items inside a useEffect after mount and dispatch them into the store with an extra reducer. Add this to the reducers object in the slice, and export it with the other actions:
Ts
hydrate(state, action: PayloadAction<CartItem[]>) {
  state.items = action.payload;
},
The provider then creates the store once per browser session and loads the saved cart after mount:
Tsx
// StoreProvider.tsx
"use client";

import { useEffect, useRef } from "react";
import { Provider } from "react-redux";
import { makeStore, type AppStore } from "./store";
import { hydrate } from "./cartSlice";
import { loadCart } from "./storage";

export default function StoreProvider({ children }: { children: React.ReactNode }) {
  const storeRef = useRef<AppStore | null>(null);
  if (!storeRef.current) storeRef.current = makeStore();

  useEffect(() => {
    storeRef.current?.dispatch(hydrate(loadCart()));
  }, []);

  return <Provider store={storeRef.current}>{children}</Provider>;
}
This uses the makeStore and AppStore exports from the store file above. Creating the store inside the provider, not as a module-level singleton, matters because a shared server-side store would leak one visitor's cart into another's render. The badge may flash from 0 to the real count on first load, which is a small price for correct markup. The provider has to live in a client component, which is what the "use client" line does. The Next.js docs on Client Components explain that boundary. Reducers are pure functions, which makes them the easiest part of a Redux app to test. You do not need React or a store. Pass in a state and an action, then look at what comes back.
Ts
import { it, expect } from "vitest";
import reducer, { addItem, setQuantity } from "./cartSlice";

const tee = { id: "tee", name: "Tee", priceInCents: 1999 };

it("adds a new line, then increases its quantity", () => {
  let state = reducer(undefined, addItem(tee));
  state = reducer(state, addItem(tee));
  expect(state.items).toEqual([{ ...tee, quantity: 2 }]);
});

it("removes the line when the quantity drops to zero", () => {
  let state = reducer(undefined, addItem(tee));
  state = reducer(state, setQuantity({ id: "tee", quantity: 0 }));
  expect(state.items).toHaveLength(0);
});
Selectors are plain functions as well. Build a small object shaped like RootState, call the selector, and check the number. The two edge cases worth a test of their own are an empty cart and a quantity typed in as zero or a negative number. The Redux cart is a convenience for the user interface. Anyone can open devtools, change a price in state or in localStorage, and click checkout. So when the user pays, send only product ids and quantities to your API. The server loads prices from its own database, adds tax, shipping and discounts, and calculates the total itself. The Stripe Checkout guide for a MERN stack app shows that server route in full. If your catalogue lives in a relational database, the Prisma schema design post is a good place to see how products and orders can be modelled for that lookup. Cover photo from Unsplash.
Share this post: