Skip to main content

πŸ›’ Weekend Project: Build a Redux-Powered E-commerce Shopping Cart

Nothing exercises a state library like a shopping cart. It has to fetch a catalog from an API, add and remove items, bump quantities up and down, keep a running total that is always correct, and remember everything if the shopper closes the tab and comes back. This weekend you'll build exactly that with Redux Toolkit β€” the modern, batteries-included Redux β€” and in the process wire together every idea from this week: a configured store, an async thunk, Immer-powered reducers, memoized selectors, and React bindings with useSelector and useDispatch.

Week 6 · Weekend Project · Redux Capstone

🎯 Learning Objectives

By completing this project, you will be able to:

  • Configure a Redux store with configureStore and provide it to a React app with <Provider>
  • Fetch a product catalog asynchronously with createAsyncThunk, tracking loading, success, and error states
  • Model cart operations with createSlice and write "mutating" reducers safely thanks to Immer
  • Derive cart totals with memoized selectors using createSelector from Reselect
  • Connect components to the store with useSelector and useDispatch, and prevent needless re-renders
  • Persist the cart to localStorage so it survives a page refresh

Estimated Time: 5–8 hours across the weekend

Project: A working storefront with a live-fetched product grid, an add/remove/update-quantity cart, a correct memoized total, and a cart that persists across reloads.

In This Project

The Goal

Build a single-page storefront a real shopper would use: a grid of products loaded from a live API on the left, a cart panel on the right. Click Add to Cart and the item appears with a quantity of one; click it again and the quantity climbs. Nudge quantities up and down, remove a line, watch the total stay perfectly in sync, then refresh the page β€” the cart is still there. Every one of those interactions is a Redux action flowing through a reducer to update one central store, and every piece of UI is a pure function of that store.

The mental model is the whole point of Redux. There is exactly one source of truth β€” the store β€” split into feature "slices." Components never mutate it directly; they dispatch actions, reducers compute the next state, and subscribed components re-render. Here is the store you're building toward:

graph TD Store["Redux Store
single source of truth"] --> P["products slice"] Store --> C["cart slice"] P --> P1["items: array of products"] P --> P2["status: idle / loading / succeeded / failed"] P --> P3["error: message or null"] C --> C1["items: id β†’ line item + quantity"]

And here is the round trip that happens every time the shopper does anything β€” the flow you'll implement stage by stage:

graph LR UI["Component
Add to Cart click"] -->|"dispatch(addToCart(product))"| Action["Action object"] Action --> Reducer["cart reducer
Immer draft update"] Reducer --> NewState["new store state"] NewState -->|"useSelector re-reads"| UI NewState -->|"store.subscribe"| LS["localStorage"]

πŸ“– Why Redux Toolkit, not "classic" Redux?

Old-school Redux made you hand-write action-type constants, action creators, and switch-statement reducers, plus wire up redux-thunk and the DevTools yourself β€” a lot of boilerplate for every feature. Redux Toolkit (RTK) is now the official, recommended way to write Redux. createSlice generates your actions and reducers together, configureStore sets up the DevTools and thunk middleware automatically, and Immer lets you write reducers that look like they mutate state while actually producing a new immutable copy. Same predictable Redux, a fraction of the code.

Prerequisites

This is the Week 6 capstone, so it assumes the whole "State Management with Redux" week. Before you start, make sure you're comfortable with:

  • The Redux data flow β€” store, actions, reducers, and the one-way dispatch β†’ reducer β†’ new state β†’ re-render cycle
  • createSlice β€” how a slice bundles a name, initial state, and reducers into auto-generated action creators
  • useSelector and useDispatch β€” the React-Redux hooks for reading state and dispatching actions
  • Immutable updates β€” and how Immer lets you "mutate a draft" inside a slice reducer instead
  • Async thunks β€” the idea that a thunk dispatches pending / fulfilled / rejected around an API call
  • React fundamentals from Weeks 4–5 β€” components, props, useEffect, and controlled inputs

You'll need Node.js 18+ (which brings npm), an editor, and a terminal. Check with node --version. The catalog comes from the free Fake Store API β€” no key, no signup, just a public endpoint returning JSON products.

Required Features Checklist

These are the non-negotiables. Every one is achievable with Redux Toolkit and React-Redux alone β€” no extra state libraries. Tick each off as you go.

βœ… Must-have features

  • ☐ Product list β€” render a grid of products fetched asynchronously from an API, with loading and error states
  • ☐ Add to cart β€” clicking a product adds it (or increments its quantity if already present)
  • ☐ Remove from cart β€” a line can be removed entirely
  • ☐ Update quantity β€” increment / decrement, with quantity 0 removing the line
  • ☐ Cart totals β€” item count and money total computed via memoized selectors (createSelector)
  • ☐ Persist the cart β€” save to localStorage so it survives a refresh
  • ☐ Redux Toolkit only: configureStore, createSlice, createAsyncThunk; reducers written with Immer; totals derived, never stored twice

Project Structure

Redux Toolkit apps are organized by feature folder β€” everything about "products" lives together, everything about "cart" lives together. The store just wires the slices in. Vite generates the scaffold; you add the app/, features/, and components/ folders.

redux-cart/
β”œβ”€β”€ index.html               <-- Vite entry (has <div id="root">)
β”œβ”€β”€ package.json
β”œβ”€β”€ vite.config.js
└── src/
    β”œβ”€β”€ main.jsx             <-- wraps <App /> in <Provider store={store}>
    β”œβ”€β”€ App.jsx              <-- layout: ProductList + Cart
    β”œβ”€β”€ App.css
    β”œβ”€β”€ app/
    β”‚   └── store.js         <-- configureStore + localStorage glue
    β”œβ”€β”€ features/
    β”‚   β”œβ”€β”€ products/
    β”‚   β”‚   └── productsSlice.js   <-- createAsyncThunk fetch + slice + selectors
    β”‚   └── cart/
    β”‚       └── cartSlice.js       <-- createSlice reducers + memoized selectors
    └── components/
        β”œβ”€β”€ ProductList.jsx  <-- dispatches fetch, renders grid
        β”œβ”€β”€ ProductCard.jsx  <-- one product, Add to Cart button
        β”œβ”€β”€ Cart.jsx         <-- lists cart lines + total
        └── CartLine.jsx     <-- one cart row: qty controls + remove

The rule that keeps this maintainable: all state lives in slices, all state changes happen in reducers. Components read with selectors and write by dispatching actions. When a total is wrong, you know it's in a selector or a reducer β€” never scattered across ten components.

πŸ’‘ Why keep selectors in the slice file?

Co-locating selectors (selectCartItems, selectCartTotal) with the slice means components never reach into the state shape directly. If you later change how the cart stores items β€” say, from an object map to an array β€” you fix the selectors in one file and every component keeps working. This is the "encapsulate the shape" habit the Redux style guide recommends.

Stage 1 β€” Scaffold & configureStore

Create the project with Vite and install Redux Toolkit plus the React bindings. RTK already includes Immer and Reselect, so this is the entire dependency list:

# Scaffold a React project (choose "React" β†’ "JavaScript" if prompted)
npm create vite@latest redux-cart -- --template react
cd redux-cart

# Redux Toolkit + the official React bindings
npm install @reduxjs/toolkit react-redux

# Install the rest and start the dev server
npm install
npm run dev

Now create the store. configureStore takes a reducer map β€” one key per slice β€” and in return sets up the Redux DevTools and the thunk middleware for you. We'll add the two slice reducers here (you'll write them in Stages 2 and 3):

// src/app/store.js
import { configureStore } from '@reduxjs/toolkit';
import productsReducer from '../features/products/productsSlice';
import cartReducer from '../features/cart/cartSlice';

export const store = configureStore({
  reducer: {
    products: productsReducer,   // state.products
    cart: cartReducer,           // state.cart
  },
});

Then hand the store to React once, at the very top, with <Provider>. Every component below it can now reach the store through the hooks β€” no prop drilling:

// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </StrictMode>
);

πŸ“– What configureStore did for free

With classic Redux you'd call createStore, then manually applyMiddleware(thunk), then wire up the DevTools extension with an awkward compose dance. configureStore does all three automatically, and in development it even adds checks that warn you if you accidentally mutate state outside a reducer or put non-serializable values in the store. Less setup, more guardrails.

Stage 2 β€” Products Slice (async fetch)

The catalog comes from an API, so fetching it is asynchronous. In RTK the clean way to do that is createAsyncThunk: you give it an action name and an async function, and it automatically dispatches three lifecycle actions β€” pending when the request starts, fulfilled with the data on success, and rejected with the error on failure. You handle those three in the slice's extraReducers.

// src/features/products/productsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

// The async thunk: RTK dispatches pending / fulfilled / rejected around it.
export const fetchProducts = createAsyncThunk(
  'products/fetchProducts',
  async (_, { rejectWithValue }) => {
    try {
      // Native fetch β€” no axios needed. Throw on a non-2xx status.
      const res = await fetch('https://fakestoreapi.com/products');
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return await res.json();          // becomes action.payload on "fulfilled"
    } catch (err) {
      // Send a clean message to the "rejected" case instead of throwing.
      return rejectWithValue(err.message);
    }
  }
);

const initialState = {
  items: [],
  status: 'idle',   // 'idle' | 'loading' | 'succeeded' | 'failed'
  error: null,
};

const productsSlice = createSlice({
  name: 'products',
  initialState,
  reducers: {},                          // no synchronous product actions needed
  extraReducers: (builder) => {
    builder
      .addCase(fetchProducts.pending, (state) => {
        state.status = 'loading';
        state.error = null;
      })
      .addCase(fetchProducts.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.items = action.payload;    // the array of products
      })
      .addCase(fetchProducts.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.payload;    // the message from rejectWithValue
      });
  },
});

// Selectors β€” components read through these, never state.products.* directly.
export const selectAllProducts = (state) => state.products.items;
export const selectProductsStatus = (state) => state.products.status;
export const selectProductsError = (state) => state.products.error;

export default productsSlice.reducer;

πŸ’‘ A status string beats a lone isLoading boolean

A single boolean can't tell "haven't fetched yet" (idle) apart from "finished, but empty" (succeeded). The four-state string models the request's full lifecycle, so your UI can show a spinner, an error with a retry, an empty state, or the grid β€” each exactly when it should. This idle β†’ loading β†’ succeeded/failed pattern is the RTK convention you'll reuse in every data-fetching slice.

πŸ“– Prefer even less code? RTK Query

For pure server data, Redux Toolkit ships RTK Query, which generates the thunk, the caching, the loading flags, and a React hook from a single endpoint definition β€” you'd just call const { data, isLoading } = useGetProductsQuery(). We hand-write the thunk here because it makes the pending/fulfilled/rejected mechanics visible, which is the point of the exercise. Once you've felt how it works, reach for RTK Query in real apps.

Stage 3 β€” Cart Slice (Immer reducers)

The cart is where Redux earns its keep. We store items as an object keyed by product id β€” { [id]: { ...product, quantity } } β€” which makes "is this already in the cart?" an instant lookup and "update this line" a direct key access, no array scanning. Every reducer below looks like it mutates state, but because RTK wraps reducers in Immer, each one actually produces a brand-new immutable state. You get readable code and immutability at the same time.

// src/features/cart/cartSlice.js
import { createSlice } from '@reduxjs/toolkit';

const initialState = {
  items: {},   // { [productId]: { id, title, price, image, quantity } }
};

const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    // Add a product, or bump its quantity if it's already in the cart.
    addToCart: (state, action) => {
      const product = action.payload;
      const line = state.items[product.id];
      if (line) {
        line.quantity += 1;                       // Immer: safe "mutation"
      } else {
        state.items[product.id] = { ...product, quantity: 1 };
      }
    },

    // Remove a line entirely.
    removeFromCart: (state, action) => {
      const id = action.payload;
      delete state.items[id];                     // Immer handles the delete
    },

    // Set an explicit quantity; a quantity of 0 (or less) removes the line.
    updateQuantity: (state, action) => {
      const { id, quantity } = action.payload;
      if (quantity <= 0) {
        delete state.items[id];
      } else if (state.items[id]) {
        state.items[id].quantity = quantity;
      }
    },

    // Empty the whole cart.
    clearCart: (state) => {
      state.items = {};
    },
  },
});

// createSlice auto-generates one action creator per reducer.
export const { addToCart, removeFromCart, updateQuantity, clearCart } =
  cartSlice.actions;

export default cartSlice.reducer;

βœ… Notice what we did not store

There's no itemCount and no totalAmount field in the cart state. That's deliberate. Storing a total and the line items means two sources of truth that can drift out of sync β€” the classic bug where the cart badge says "3" but the list shows 2. Instead we store only the raw items and derive the totals with selectors in the next stage. One source of truth, always consistent.

⚠️ The Immer rule: mutate the draft or return new state β€” never both

Inside a createSlice reducer you may either change the state draft in place (as above) or return a completely new object β€” but not both in the same reducer. Mixing them confuses Immer and throws. When in doubt, pick one style per reducer; the "mutate the draft" style is the reason RTK reducers stay so short.

Stage 4 β€” Connect the UI

Now the React side. Components read state with useSelector and send actions with useDispatch. Start with App, then the product grid that kicks off the fetch.

The app shell

// src/App.jsx
import ProductList from './components/ProductList';
import Cart from './components/Cart';
import './App.css';

export default function App() {
  return (
    <div className="store">
      <header><h1>πŸ›’ The Redux Store</h1></header>
      <div className="store-layout">
        <ProductList />
        <Cart />
      </div>
    </div>
  );
}

ProductList β€” dispatch the fetch, render the grid

On mount, dispatch fetchProducts() only if we haven't already (guarding on status === 'idle' so it never double-fetches). Then branch on the status to show a spinner, an error, or the grid:

// src/components/ProductList.jsx
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
  fetchProducts,
  selectAllProducts,
  selectProductsStatus,
  selectProductsError,
} from '../features/products/productsSlice';
import ProductCard from './ProductCard';

export default function ProductList() {
  const dispatch = useDispatch();
  const products = useSelector(selectAllProducts);
  const status = useSelector(selectProductsStatus);
  const error = useSelector(selectProductsError);

  useEffect(() => {
    if (status === 'idle') dispatch(fetchProducts());
  }, [status, dispatch]);

  if (status === 'loading') return <p className="hint">Loading products…</p>;
  if (status === 'failed') {
    return (
      <div className="hint error">
        <p>Couldn't load products: {error}</p>
        <button onClick={() => dispatch(fetchProducts())}>Retry</button>
      </div>
    );
  }

  return (
    <section className="product-grid">
      {products.map((p) => (
        <ProductCard key={p.id} product={p} />
      ))}
    </section>
  );
}

ProductCard β€” dispatch addToCart

// src/components/ProductCard.jsx
import { useDispatch } from 'react-redux';
import { addToCart } from '../features/cart/cartSlice';

function ProductCard({ product }) {
  const dispatch = useDispatch();
  return (
    <article className="product-card">
      <img src={product.image} alt={product.title} />
      <h3>{product.title}</h3>
      <p className="price">${product.price.toFixed(2)}</p>
      <button onClick={() => dispatch(addToCart(product))}>
        Add to Cart
      </button>
    </article>
  );
}

// Memoize so a cart change doesn't re-render every card.
export default React.memo(ProductCard);

CartLine β€” quantity controls + remove

// src/components/CartLine.jsx
import { useDispatch } from 'react-redux';
import { updateQuantity, removeFromCart } from '../features/cart/cartSlice';

export default function CartLine({ item }) {
  const dispatch = useDispatch();
  const { id, title, price, quantity } = item;
  return (
    <li className="cart-line">
      <span className="cart-line-title">{title}</span>
      <div className="qty">
        <button
          aria-label={`Decrease quantity of ${title}`}
          onClick={() => dispatch(updateQuantity({ id, quantity: quantity - 1 }))}
        >βˆ’</button>
        <span>{quantity}</span>
        <button
          aria-label={`Increase quantity of ${title}`}
          onClick={() => dispatch(updateQuantity({ id, quantity: quantity + 1 }))}
        >+</button>
      </div>
      <span className="line-subtotal">${(price * quantity).toFixed(2)}</span>
      <button className="remove" onClick={() => dispatch(removeFromCart(id))}>
        Remove
      </button>
    </li>
  );
}

The Cart component that lists these lines and shows the total comes together in Stage 5, once the total selectors exist.

⚠️ Select the narrowest slice of state you need

A component re-renders whenever the value its useSelector returns changes (by reference). If ProductCard selected the whole cart, every quantity tweak would re-render every card. It selects nothing from the cart and is wrapped in React.memo, so it only re-renders when its own product prop changes. Keep selectors small and specific β€” it's the cheapest performance win in Redux.

Stage 5 β€” Memoized Total Selectors

The cart total and item count are derived state β€” computed from the items, never stored. The naΓ―ve way is to compute them inline in the component, but that recalculates on every render, and worse, returning a fresh array from a selector on every call defeats React-Redux's re-render optimization. The fix is createSelector (from Reselect, bundled with RTK): it memoizes, recomputing only when its inputs actually change.

Add these selectors to the bottom of cartSlice.js:

// src/features/cart/cartSlice.js β€” add below the existing selectors/exports
import { createSelector } from '@reduxjs/toolkit';

// Base selector: the raw items object.
const selectCartItemsMap = (state) => state.cart.items;

// Memoized: turn the map into an array only when the map changes.
export const selectCartItems = createSelector(
  [selectCartItemsMap],
  (itemsMap) => Object.values(itemsMap)
);

// Memoized total item count (sum of quantities).
export const selectCartCount = createSelector(
  [selectCartItems],
  (items) => items.reduce((sum, item) => sum + item.quantity, 0)
);

// Memoized money total.
export const selectCartTotal = createSelector(
  [selectCartItems],
  (items) => items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);

πŸ’‘ How memoization saves a render here

Object.values(itemsMap) creates a new array every time it runs. Without memoization, useSelector(selectCartItems) would see a new array reference on every store change β€” even an unrelated one β€” and force the cart to re-render. createSelector caches the result and returns the same array reference until items genuinely changes, so the cart re-renders only when the cart really changed. Chaining selectCartTotal off selectCartItems reuses that cache too.

The Cart component

Now the cart reads its data entirely through memoized selectors and dispatches clearCart:

// src/components/Cart.jsx
import { useSelector, useDispatch } from 'react-redux';
import {
  selectCartItems,
  selectCartCount,
  selectCartTotal,
  clearCart,
} from '../features/cart/cartSlice';
import CartLine from './CartLine';

export default function Cart() {
  const dispatch = useDispatch();
  const items = useSelector(selectCartItems);
  const count = useSelector(selectCartCount);
  const total = useSelector(selectCartTotal);

  if (items.length === 0) {
    return <aside className="cart"><h2>Your Cart</h2><p>Your cart is empty.</p></aside>;
  }

  return (
    <aside className="cart">
      <h2>Your Cart ({count})</h2>
      <ul className="cart-lines">
        {items.map((item) => (
          <CartLine key={item.id} item={item} />
        ))}
      </ul>
      <div className="cart-footer">
        <strong>Total: ${total.toFixed(2)}</strong>
        <button className="clear" onClick={() => dispatch(clearCart())}>
          Clear Cart
        </button>
      </div>
    </aside>
  );
}

πŸ“– One click, traced end to end

Shopper clicks + on a cart line β†’ CartLine dispatches updateQuantity({ id, quantity: quantity + 1 }) β†’ the cart reducer bumps that line's quantity via Immer β†’ the store holds new state β†’ selectCartTotal recomputes (its input changed) β†’ Cart re-renders with the new total. You never touched the DOM or a total variable; you dispatched one action and the derived UI followed.

Stage 6 β€” Persist to localStorage

Right now a refresh empties the cart. The classic Redux persistence pattern is two small functions plus a store.subscribe: load the saved cart as the store's preloaded state, and save the cart slice on every change. Update store.js:

// src/app/store.js β€” now with localStorage persistence
import { configureStore } from '@reduxjs/toolkit';
import productsReducer from '../features/products/productsSlice';
import cartReducer from '../features/cart/cartSlice';

const CART_KEY = 'redux-cart:cart';

// Read the saved cart, degrading gracefully if it's missing or corrupt.
function loadCart() {
  try {
    const raw = localStorage.getItem(CART_KEY);
    return raw ? JSON.parse(raw) : undefined;   // undefined β†’ slice's initialState
  } catch {
    return undefined;
  }
}

// Write the cart slice back to storage.
function saveCart(cartState) {
  try {
    localStorage.setItem(CART_KEY, JSON.stringify(cartState));
  } catch {
    // Storage full or blocked (private mode) β€” safe to ignore.
  }
}

export const store = configureStore({
  reducer: {
    products: productsReducer,
    cart: cartReducer,
  },
  // Seed only the cart slice; products always fetch fresh.
  preloadedState: { cart: loadCart() },
});

// Persist the cart whenever it changes. (Products aren't persisted.)
store.subscribe(() => {
  saveCart(store.getState().cart);
});

⚠️ Persist the cart, not the products

We deliberately preload only the cart slice. Product data should always come fresh from the API β€” prices and availability change β€” so persisting a stale catalog would be a bug, not a feature. And always wrap JSON.parse in a try/catch: a corrupted or blocked localStorage would otherwise throw before your app even renders. Returning undefined lets the slice fall back to its initialState.

πŸ’‘ Throttling the writes (optional polish)

store.subscribe fires on every dispatch, so a fast burst of clicks writes to storage many times. It's harmless at this scale, but the production habit is to throttle the save (e.g. with a small debounce) so you write at most a few times per second. For a stretch, wrap saveCart in a 500 ms debounce and you've matched what libraries like redux-persist do under the hood.

A minimal App.css

Styling isn't the point, but a two-column layout makes the store readable. Drop this in src/App.css:

.store { max-width: 1000px; margin: 1.5rem auto; padding: 0 1rem; font-family: system-ui, sans-serif; }
.store-layout { display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; align-items: start; }
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; }
.product-card { border: 1px solid #ddd; border-radius: 8px; padding: 0.75rem; text-align: center; }
.product-card img { height: 120px; object-fit: contain; }
.cart { border: 1px solid #ddd; border-radius: 8px; padding: 1rem; position: sticky; top: 1rem; }
.cart-lines { list-style: none; padding: 0; }
.cart-line { display: grid; grid-template-columns: 1fr auto auto auto; gap: 0.5rem; align-items: center; padding: 0.4rem 0; }
.qty { display: flex; align-items: center; gap: 0.4rem; }
.cart-footer { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #ddd; margin-top: 0.5rem; padding-top: 0.5rem; }
@media (max-width: 700px) { .store-layout { grid-template-columns: 1fr; } }

Stretch Goals

Finished the required build with time to spare? Level it up β€” pick whichever excites you; none are needed to pass the rubric.

  • πŸ”Ž Category filter β€” a ui slice holding the active category, with a memoized selector returning the filtered product list
  • ⚑ Switch to RTK Query β€” replace the products thunk with a createApi endpoint and a generated useGetProductsQuery() hook
  • 🧾 Free-shipping bar β€” derive "you're $X away from free shipping" from selectCartTotal with another memoized selector
  • 🏷️ Discount codes β€” a reducer that stores an applied code and a selector that computes the discounted total
  • πŸ’Ύ Debounced persistence β€” throttle saveCart so rapid clicks write at most twice a second
  • πŸš€ Deploy it β€” npm run build and publish the dist/ folder to Netlify or Vercel

RTK Query starter (for the ambitious)

Here's how little code the products fetch becomes with RTK Query β€” the whole thunk, cache, and hook in one endpoint:

// src/features/products/productsApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const productsApi = createApi({
  reducerPath: 'productsApi',
  baseQuery: fetchBaseQuery({ baseUrl: 'https://fakestoreapi.com/' }),
  endpoints: (builder) => ({
    getProducts: builder.query({ query: () => 'products' }),
  }),
});

export const { useGetProductsQuery } = productsApi;
// In ProductList: const { data: products = [], isLoading, isError } = useGetProductsQuery();

You'd add [productsApi.reducerPath]: productsApi.reducer to the store and concat productsApi.middleware. That's the modern production path β€” but writing the thunk by hand first is exactly what makes RTK Query feel like magic rather than mystery.

Self-Check Rubric

Before you call this done, grade yourself against the rubric. Aim to answer "yes" to everything in the first two columns β€” the stretch column is bonus.

AreaMeets expectations (required)Exceeds (stretch)
Store setup configureStore with products + cart slices; app wrapped in <Provider> DevTools time-travel used to debug a dispatch
Async fetch Products load via createAsyncThunk with loading / error / success states Retry on failure; migrated to RTK Query
Cart reducers Add, remove, and update-quantity all work; written with Immer in createSlice Discount-code reducer; quantity 0 removes the line
Derived totals Count and money total come from createSelector β€” not stored in state Free-shipping / savings selectors chained off the total
UI wiring Components use useSelector/useDispatch; selectors are narrow; cards memoized Verified no needless re-renders with the DevTools profiler
Persistence & quality Cart survives refresh via localStorage; no console errors Debounced writes; deployed live

πŸ§ͺ Final testing checklist

  • ☐ On load, a spinner shows, then the product grid appears (kill your network to see the error + retry)
  • ☐ Clicking a product adds it; clicking again increments the same line's quantity
  • ☐ The + / βˆ’ buttons change the quantity; going to 0 removes the line
  • ☐ Remove deletes the correct line; Clear Cart empties everything
  • ☐ The item count and money total are always correct after any change
  • ☐ Refreshing the page keeps the cart exactly as it was
  • ☐ No red errors and no "non-serializable" or key warnings in the console

Summary

πŸŽ‰ What You Built

  • A working storefront powered entirely by Redux Toolkit β€” configureStore, two createSlice slices, and a createAsyncThunk fetch
  • An async products slice modeling the full idle β†’ loading β†’ succeeded β†’ failed lifecycle
  • A cart slice whose add / remove / update-quantity reducers read like mutations but stay immutable thanks to Immer
  • Memoized total selectors with createSelector β€” totals derived from one source of truth, never stored twice
  • React components wired with useSelector / useDispatch, plus localStorage persistence that survives a refresh

This project is proof that Week 6 stuck. You took the whole Redux data flow β€” dispatch an action, let a reducer compute new state, let subscribed components re-render β€” and applied it to a genuinely stateful app with async data, interdependent totals, and persistence. The moves you practiced here (slice per feature, derive don't duplicate, select narrowly, thunk your async) are the everyday grammar of production Redux.

πŸ“š Additional Resources

πŸš€ What's Next?

You've now mastered state on the client. But that product catalog came from someone else's server β€” and every real app eventually needs its own. Week 7 crosses to the back end: you'll meet Node.js, learn why JavaScript on the server changed everything, and start building the APIs your React apps will talk to. The same language, a whole new half of the stack.

πŸŽ‰ You finished Week 6!

You shipped a Redux-powered store with async data, memoized totals, and persistence. Deploy it, share the URL, and put it in your portfolio β€” you're thinking in slices and selectors now.