Skip to main content

πŸ› οΈ 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 createSelector to derive filtered/derived data cheaply
  • Split routes with React.lazy + Suspense and tame re-renders with memo, useMemo, and useCallback
  • 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.

The dashboard's four data domains Four cards β€” Products, Orders, Users, and Analytics β€” showing the key fields each domain holds. Products and Orders feed the Analytics domain. Products id Β· name Β· price Β· category inStock Β· inventory Β· image the catalog you manage Orders id Β· date Β· status Β· total customer Β· line items what customers bought Users id Β· name Β· email Β· role who can sign in Analytics daily sales Β· top products Β· KPIs derived from products + orders
Figure 1 β€” Four small domains. Analytics is derived from the other data rather than stored separately, which is exactly what memoized selectors are for.

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:

flowchart LR A[Understand
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

MilestoneYou'll finish withRough time
1 β€” Scaffold & StoreProject running, mock API serving data, empty store wired to React2–3 h
2 β€” A Feature SliceProducts slice: adapter, thunk, filter selector, all tested in the console3–4 h
3 β€” Lazy Routes & UIRouter with code-split pages, product list + filters rendering real data4–6 h
4 β€” Performance PassVirtualized list, memoized components, measured before/after3–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);
flowchart TD A[Profile with
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
  • configureStore wired; <Provider> at the root; DevTools shows the state tree

🧩 State management

  • A slice per feature using createSlice
  • Normalized state via createEntityAdapter
  • Async loading via createAsyncThunk with 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.memo with 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.

AreaJust workingWhat 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

πŸš€ 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.