π 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
configureStoreand 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
createSliceand write "mutating" reducers safely thanks to Immer - Derive cart totals with memoized selectors using
createSelectorfrom Reselect - Connect components to the store with
useSelectoranduseDispatch, and prevent needless re-renders - Persist the cart to
localStorageso 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:
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:
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 creatorsuseSelectoranduseDispatchβ 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
localStorageso 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
uislice holding the active category, with a memoized selector returning the filtered product list - β‘ Switch to RTK Query β replace the products thunk with a
createApiendpoint and a generateduseGetProductsQuery()hook - π§Ύ Free-shipping bar β derive "you're $X away from free shipping" from
selectCartTotalwith another memoized selector - π·οΈ Discount codes β a reducer that stores an applied code and a selector that computes the discounted total
- πΎ Debounced persistence β throttle
saveCartso rapid clicks write at most twice a second - π Deploy it β
npm run buildand publish thedist/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.
| Area | Meets 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, twocreateSliceslices, and acreateAsyncThunkfetch - 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, pluslocalStoragepersistence 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
- Redux Toolkit β Getting Started
- Redux Toolkit β
createAsyncThunk - Redux β Deriving data with selectors (Reselect)
- Redux Toolkit β RTK Query overview
- Fake Store API β free product data
π 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.