π οΈ Weekend Project: Advanced React & State Management
Time to put the whole module to work. Over one focused weekend you'll build a small but real e-commerce admin dashboard β products, orders, and analytics β using Redux Toolkit for state and modern React for performance. This is a guided build with clear milestones, a checklist to tick off, and an honest bar for "what good looks like".
π― Learning Objectives
By the end of this project, you will be able to:
- Structure a multi-feature React app with feature-folder Redux Toolkit slices
- Model normalized state with createEntityAdapter and load it with createAsyncThunk
- Write memoized selectors with
createSelectorto derive filtered/derived data cheaply - Split routes with React.lazy + Suspense and tame re-renders with
memo,useMemo, anduseCallback - Measure your work with the React DevTools Profiler and web-vitals β and judge it against a concrete quality bar
Estimated Time: 12β20 hours (a weekend, split into milestones) β’ Difficulty: Advanced
Hands-on: This entire lesson is the exercise β build the dashboard milestone by milestone and check it against the rubric.
In This Lesson
What You're Building
Your deliverable is an e-commerce admin dashboard: the back-office screen a shop owner uses to manage their store. It has four areas β a products catalog, an orders list, a lightweight users view, and a sales analytics page with charts. It talks to a fake REST API so you can focus entirely on the frontend.
The point of the project is not to ship a startup. It's to exercise, in one connected app, every advanced pattern from this module: Redux Toolkit slices, normalized entity state, async thunks, memoized selectors, lazy-loaded routes, and render optimization. Small app, deep patterns.
π The tech stack you'll use
React 18/19 (function components + hooks) β’ Redux Toolkit + react-redux hooks β’ React Router v6 β’ @tanstack/react-virtual for long lists β’ Chart.js via react-chartjs-2 β’ json-server as a zero-code mock API.
Understand & Plan the Build
Before writing code, spend fifteen minutes planning. A classic, dependable frame is George PΓ³lya's four steps β understand, plan, execute, reflect. It maps cleanly onto the four milestones ahead:
the problem] --> B[Devise
a plan] B --> C[Execute
milestone by milestone] C --> D[Review
& measure] D -->|refine| C
Decide the state shape first
The single most important design decision in a Redux app is the shape of the store. Sketch it on paper before you type. A good shape for this project is one slice per feature, each holding a normalized table of entities plus a little UI state (status, filters):
// The target store shape (conceptually)
{
products: {
ids: [1, 2, 3], // order preserved by the adapter
entities: { 1: {...}, 2: {...} }, // O(1) lookup by id
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
filters: { category: null, inStock: null, maxPrice: null }
},
orders: { ids: [], entities: {}, status: 'idle', error: null },
users: { ids: [], entities: {}, status: 'idle', error: null },
analytics: { data: null, status: 'idle', error: null }
}
π‘ Why normalize?
Storing entities keyed by id (instead of a big array) means updating one product is a single key write, not a scan-and-replace of the whole list. createEntityAdapter gives you this shape β plus add/update/remove reducers and ready-made selectors β for free.
The four milestones
| Milestone | You'll finish with | Rough time |
|---|---|---|
| 1 β Scaffold & Store | Project running, mock API serving data, empty store wired to React | 2β3 h |
| 2 β A Feature Slice | Products slice: adapter, thunk, filter selector, all tested in the console | 3β4 h |
| 3 β Lazy Routes & UI | Router with code-split pages, product list + filters rendering real data | 4β6 h |
| 4 β Performance Pass | Virtualized list, memoized components, measured before/after | 3β4 h |
Work them in order. Each milestone leaves you with something that runs β never spend hours in a broken state.
Milestone 1 β Scaffold & Store
Start with a modern toolchain. Vite is the current standard for React apps (Create React App is deprecated), so we'll use it.
# Scaffold a React app with Vite
npm create vite@latest ecommerce-dashboard -- --template react
cd ecommerce-dashboard
# State, routing, data-fetching, charts, virtualization
npm install @reduxjs/toolkit react-redux react-router-dom
npm install chart.js react-chartjs-2 @tanstack/react-virtual date-fns
# A zero-code mock REST API
npm install --save-dev json-server web-vitals
Create a db.json at the project root with a little seed data, then serve it:
{
"products": [
{ "id": 1, "name": "Aurora Desk Lamp", "price": 39.99, "category": "Lighting", "inStock": true, "inventory": 120 },
{ "id": 2, "name": "Nomad Backpack", "price": 89.0, "category": "Bags", "inStock": true, "inventory": 44 },
{ "id": 3, "name": "Ceramic Pour-Over", "price": 24.5, "category": "Kitchen", "inStock": false, "inventory": 0 }
],
"orders": [],
"users": []
}
# Serves a REST API at http://localhost:4000
npx json-server --watch db.json --port 4000
Now wire Redux Toolkit. The configureStore helper sets up the store with good defaults (Redux DevTools, thunk middleware, an immutability check) β no boilerplate:
// src/app/store.js
import { configureStore } from '@reduxjs/toolkit';
import productsReducer from '../features/products/productsSlice';
// import ordersReducer from '../features/orders/ordersSlice'; // added in later features
// import usersReducer from '../features/users/usersSlice';
// import analyticsReducer from '../features/analytics/analyticsSlice';
export const store = configureStore({
reducer: {
products: productsReducer,
// orders: ordersReducer,
// users: usersReducer,
// analytics: analyticsReducer,
},
});
Provide the store to React at the root. With React 18/19 you mount through createRoot:
// src/main.jsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
β Milestone 1 done whenβ¦
Visiting http://localhost:4000/products returns your seed JSON, npm run dev renders a page, and the Redux DevTools tab shows a products slice in the state tree. Commit here.
Milestone 2 β A Feature Slice
This is the heart of the module. Build the products slice completely; the other three slices are copies of this pattern.
The API service
Keep network code out of your components. A tiny fetch wrapper is plenty:
// src/services/api.js
const BASE = 'http://localhost:4000';
async function request(path, options) {
const res = await fetch(`${BASE}${path}`, options);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
export const getProducts = () => request('/products');
export const getOrders = () => request('/orders');
The slice: adapter + thunk + selectors
Note the modern API: createEntityAdapter for the normalized table, createAsyncThunk for the load, and createSelector for cheap filtering. This replaces the hundreds of lines of hand-written action types and reducers that legacy Redux required.
// src/features/products/productsSlice.js
import {
createSlice,
createAsyncThunk,
createEntityAdapter,
createSelector,
} from '@reduxjs/toolkit';
import { getProducts } from '../../services/api';
// 1) Normalized table, sorted by name
const productsAdapter = createEntityAdapter({
sortComparer: (a, b) => a.name.localeCompare(b.name),
});
// 2) Async load
export const fetchProducts = createAsyncThunk(
'products/fetch',
async () => await getProducts() // return value becomes action.payload
);
// 3) Initial state = adapter's { ids, entities } + our UI state
const initialState = productsAdapter.getInitialState({
status: 'idle',
error: null,
filters: { category: null, inStock: null, maxPrice: null },
});
const productsSlice = createSlice({
name: 'products',
initialState,
reducers: {
// Immer lets us "mutate" safely β RTK produces the immutable update
setFilter(state, action) {
const { key, value } = action.payload;
state.filters[key] = value;
},
clearFilters(state) {
state.filters = initialState.filters;
},
productAdded: productsAdapter.addOne,
productUpdated: productsAdapter.updateOne,
productRemoved: productsAdapter.removeOne,
},
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, (state) => { state.status = 'loading'; })
.addCase(fetchProducts.fulfilled, (state, action) => {
state.status = 'succeeded';
productsAdapter.setAll(state, action.payload);
})
.addCase(fetchProducts.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
},
});
export const { setFilter, clearFilters, productAdded, productUpdated, productRemoved } =
productsSlice.actions;
export default productsSlice.reducer;
// 4) Adapter selectors (memoized, id-based)
export const {
selectAll: selectAllProducts,
selectById: selectProductById,
} = productsAdapter.getSelectors((state) => state.products);
const selectFilters = (state) => state.products.filters;
// 5) Derived, memoized filtered list β only recomputes when inputs change
export const selectFilteredProducts = createSelector(
[selectAllProducts, selectFilters],
(products, filters) =>
products.filter((p) => {
if (filters.category && p.category !== filters.category) return false;
if (filters.inStock !== null && p.inStock !== filters.inStock) return false;
if (filters.maxPrice != null && p.price > filters.maxPrice) return false;
return true;
})
);
β οΈ Common slice mistakes
Don't put non-serializable values (class instances, functions, Dates) in state β keep dates as ISO strings. Don't write a createSelector whose input function returns a fresh array/object every call (e.g. state => ({ ...x })) β that defeats memoization. And remember Immer only works inside RTK reducers; elsewhere, treat state as read-only.
β Milestone 2 done whenβ¦
In the browser console you can run store.dispatch(window.__fetchProducts?.()) (or trigger it from a temporary button) and watch the DevTools show loading β succeeded with entities populated. Selectors return the right filtered set as you dispatch setFilter.
Milestone 3 β Lazy Routes & UI
Now render it. Split each page into its own bundle with React.lazy so the browser only downloads the code for the route the user actually visits. Wrap the tree in Suspense to show a fallback while a chunk loads.
// src/App.jsx
import { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import MainLayout from './components/layout/MainLayout';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const ProductsPage = lazy(() => import('./pages/ProductsPage'));
const OrdersPage = lazy(() => import('./pages/OrdersPage'));
const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage'));
export default function App() {
return (
<BrowserRouter>
<Suspense fallback={<p>Loadingβ¦</p>}>
<Routes>
<Route path="/" element={<MainLayout />}>
<Route index element={<Dashboard />} />
<Route path="products" element={<ProductsPage />} />
<Route path="orders" element={<OrdersPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
</Route>
</Routes>
</Suspense>
</BrowserRouter>
);
}
The products page reads from the store with the typed react-redux hooks. Notice how little the component knows: it dispatches the thunk once, then subscribes to the memoized selector.
// src/pages/ProductsPage.jsx
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchProducts, selectFilteredProducts } from '../features/products/productsSlice';
import ProductFilters from '../components/products/ProductFilters';
import ProductList from '../components/products/ProductList';
export default function ProductsPage() {
const dispatch = useDispatch();
const status = useSelector((s) => s.products.status);
const products = useSelector(selectFilteredProducts);
useEffect(() => {
if (status === 'idle') dispatch(fetchProducts());
}, [status, dispatch]);
if (status === 'loading') return <p>Loading productsβ¦</p>;
if (status === 'failed') return <p role="alert">Could not load products.</p>;
return (
<section>
<h1>Products ({products.length})</h1>
<ProductFilters />
<ProductList products={products} />
</section>
);
}
The filters component dispatches setFilter. Wrap its handlers in useCallback so their identities stay stable across renders (this matters once children are memoized):
// src/components/products/ProductFilters.jsx
import { memo, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { setFilter, clearFilters } from '../../features/products/productsSlice';
function ProductFilters() {
const dispatch = useDispatch();
const filters = useSelector((s) => s.products.filters);
const onCategory = useCallback(
(e) => dispatch(setFilter({ key: 'category', value: e.target.value || null })),
[dispatch]
);
const onInStock = useCallback(
(e) => dispatch(setFilter({ key: 'inStock', value: e.target.checked ? true : null })),
[dispatch]
);
return (
<div className="filters">
<input placeholder="Category" onChange={onCategory} defaultValue={filters.category ?? ''} />
<label>
<input type="checkbox" onChange={onInStock} checked={filters.inStock === true} /> In stock only
</label>
<button onClick={() => dispatch(clearFilters())}>Clear</button>
</div>
);
}
export default memo(ProductFilters);
β Milestone 3 done whenβ¦
You can navigate between /products, /orders, and /analytics; the Network tab shows a separate JS chunk loading per route; and typing in the filter box narrows the visible product list live. Repeat the slice+page pattern for orders and a Chart.js analytics page.
Milestone 4 β Performance Pass
Measure first, then optimize. Open the React DevTools β Profiler, record an interaction (e.g. typing a filter), and note which components re-render and how long commits take. Optimize only what the profiler flags β guessing wastes time.
Virtualize the long list
Rendering thousands of DOM rows is the classic dashboard killer. Windowing renders only the rows currently on screen. Here it is with the modern @tanstack/react-virtual hook:
// src/components/products/ProductList.jsx
import { memo, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import ProductRow from './ProductRow';
function ProductList({ products }) {
const parentRef = useRef(null);
const rowVirtualizer = useVirtualizer({
count: products.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72, // px per row
overscan: 8, // render a few extra above/below
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={products[virtualRow.index].id}
style={{
position: 'absolute', top: 0, left: 0, width: '100%',
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<ProductRow product={products[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
export default memo(ProductList);
Memoize the leaf rows
Wrap ProductRow in React.memo so an unrelated store change doesn't re-render every visible row. This only pays off because the parent passes stable props (the product object identity is preserved by the entity adapter, and callbacks are wrapped in useCallback):
// src/components/products/ProductRow.jsx
import { memo } from 'react';
function ProductRow({ product }) {
return (
<div className="product-row">
<span>{product.name}</span>
<span>${product.price.toFixed(2)}</span>
<span>{product.inStock ? 'In stock' : 'Out'}</span>
</div>
);
}
// Re-render only when this product's data actually changes
export default memo(ProductRow);
React DevTools] --> B{Slow commit
or wasted renders?} B -->|Long list| C[Virtualize
the list] B -->|Rows re-render| D[React.memo +
stable props] B -->|Selector recomputes| E[createSelector
memoization] B -->|Big route bundle| F[React.lazy
code split] C --> G[Re-profile
& compare] D --> G E --> G F --> G G -->|Still slow?| B G -->|Good enough| H[Ship it]
Record real numbers
Capture web-vitals so your "before/after" is data, not a feeling:
// src/reportVitals.js
import { onLCP, onCLS, onINP } from 'web-vitals';
export function reportVitals() {
onLCP(console.log); // Largest Contentful Paint β loading
onINP(console.log); // Interaction to Next Paint β responsiveness
onCLS(console.log); // Cumulative Layout Shift β visual stability
}
β Milestone 4 done whenβ¦
The product list stays smooth with 5,000 seeded items, the Profiler shows filter typing re-rendering only the filter + list (not every row), and you have written-down before/after numbers for at least one interaction.
Completion Checklist
Tick these off as you go. If a box won't tick, that's your next task.
ποΈ Setup & store
- Vite React app runs with
npm run dev - json-server serves products/orders/users at port 4000
configureStorewired;<Provider>at the root; DevTools shows the state tree
π§© State management
- A slice per feature using
createSlice - Normalized state via
createEntityAdapter - Async loading via
createAsyncThunkwith pending/fulfilled/rejected handled - At least one derived value via a memoized
createSelector
π§ UI & routing
- React Router v6 with a shared layout route
- Every page lazy-loaded (separate chunk per route in the Network tab)
- Loading and error states shown for async data
- Filters update the list live
β‘ Performance
- Long list virtualized
- Leaf rows wrapped in
React.memowith stable props/callbacks - Profiled before and after; numbers recorded
π§Ή Craft
- Feature-folder structure; network code isolated in a service
- No console errors or React key warnings
- Committed at each milestone with clear messages
What Good Looks Like
"Done" and "good" are different bars. Use this rubric to judge your build honestly β and to know where to push if you have extra time.
| Area | Just working | What good looks like |
|---|---|---|
| State design | One giant slice with arrays | Feature slices, normalized entities, selectors as the only read path |
| Async | fetch inside components, no error handling |
Thunks in slices; loading/empty/error states all rendered |
| Selectors | Filtering inline in JSX on every render | createSelector memoized; recomputes only when inputs change |
| Performance | "Feels fine" on 3 items | Smooth at thousands of rows; optimizations justified by the Profiler |
| Code health | Everything in App.jsx |
Clear folders, small components, no dead code, clean console |
β οΈ Over-optimization is a real trap
Wrapping every component in memo/useMemo/useCallback adds its own cost and clutter, and can even be slower. The mark of a strong submission is that each optimization points at a specific profiler finding. If you can't say why a useMemo is there, delete it.
Stretch goals (if the weekend's going well)
- Add optimistic updates when editing a product, rolling back on a rejected thunk.
- Swap hand-rolled thunks for RTK Query on one feature and compare the code.
- Add a route-level error boundary so a failed lazy chunk degrades gracefully.
- Persist the active filters to the URL query string so views are shareable.
Quiz
π― Quick Check
Question 1: Why store products with createEntityAdapter (a normalized { ids, entities } shape) instead of a plain array?
Question 2: Which pair correctly handles asynchronous data loading in Redux Toolkit?
Question 3: Your Profiler shows a 6,000-row product list janking while a user filters. What's the best first fix?
Summary & Next Steps
π Key Takeaways
- Design the state shape before you code: feature slices, normalized entities, selectors as the read path.
- Redux Toolkit replaces legacy boilerplate β
createSlice,createEntityAdapter,createAsyncThunk,createSelector. - Ship in milestones; keep the app runnable at every step and commit often.
- Measure before optimizing. Virtualize long lists, memoize with intent, and back every optimization with a profiler finding.
π Further Reading
- Redux Toolkit β RTK Query tutorial
- React docs β
memo,useMemo,useCallback - TanStack Virtual β list virtualization
- web.dev β Core Web Vitals
π What's Next?
You've now built a full advanced-React app end to end. In the next module we widen the lens: we'll step outside React and tour a second major framework so you can compare approaches. Up first β an overview of the Vue.js framework.
π Great build!
You've turned a module's worth of concepts into one working dashboard. That's exactly the muscle full stack work runs on.