Week 11: State Management at Scale

Week 10 gave you three tools — Context, Zustand, Redux Toolkit — and a rule of thumb for picking between them. None of that answers a different question that shows up once an app actually grows: how do you keep a store organized once it holds a dozen features instead of one cart? This week is about structure, not new primitives — organizing state by feature, shaping the data itself so updates stay cheap, and being honest about when a global store is even the right call for a given piece of data.

Module 7 of 17 Week 11 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Structure state by feature or domain instead of by data type
  • Normalize nested API responses and write memoized selectors with Reselect
  • Decide whether RTK Query, plain TanStack Query, or local state fits a given piece of data

1. From One Big Store to Feature Slices

Week 10's Redux Toolkit example had exactly one slice: cartSlice. A real app doesn't stop there — auth, cart, notifications, a product catalog, user preferences all end up needing shared state eventually. The naive way to grow is to keep adding reducers to the same flat files: one giant reducers/ folder, one giant actions/ folder, everything for every feature interleaved together by type of file rather than by what it belongs to.

the problem — organized by file type
src/
  reducers/
    cartReducer.ts
    authReducer.ts
    notificationsReducer.ts
    catalogReducer.ts
  actions/
    cartActions.ts
    authActions.ts
    notificationsActions.ts
    catalogActions.ts
  selectors/
    cartSelectors.ts
    authSelectors.ts
    ...

To understand or change one feature — say, cart — you now have to jump across three or four separate folders, and nothing in the file layout stops a cart reducer from quietly depending on an auth action. The fix isn't a new tool, just a different way of drawing folder boundaries: group everything a feature needs — its slice, its selectors, its components — together, and let file type be a detail inside each feature rather than the top-level organizing principle.

feature-sliced structure
src/
  features/
    cart/
      cartSlice.ts
      cartSelectors.ts
      CartBadge.tsx
      CartBadge.test.tsx
    auth/
      authSlice.ts
      authSelectors.ts
      LoginForm.tsx
    notifications/
      notificationsSlice.ts
      NotificationBell.tsx
  app/
    store.ts
app/store.ts — the only file that knows about every feature
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from '../features/cart/cartSlice';
import authReducer from '../features/auth/authSlice';
import notificationsReducer from '../features/notifications/notificationsSlice';

export const store = configureStore({
  reducer: {
    cart: cartReducer,
    auth: authReducer,
    notifications: notificationsReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

This is the same idea as component composition (Week 12 picks this up from a different angle) applied to state: each feature owns its slice completely, exports only what other features actually need, and store.ts becomes the single place that wires everything together — a map of the app, not a place where feature logic lives.

This applies to Zustand too

Feature-based structure isn't Redux-specific. A large Zustand app benefits from the same split — one store file per feature (or one store with feature-namespaced slices combined via Zustand's slices pattern) instead of a single 500-line useAppStore.ts.

2. Normalizing Nested Data

API responses are often deeply nested — an order with line items, each line item with a nested product, each product with nested category data. Storing that shape directly in your store makes updating any single piece of it painful: to immutably update one product's price three levels deep, you have to spread every level above it correctly, and a single missed spread silently mutates state Redux Toolkit's Immer would otherwise have protected you from (Week 10).

the problem — deeply nested state
interface OrderState {
  orders: {
    id: string;
    lineItems: {
      id: string;
      quantity: number;
      product: { id: string; name: string; price: number };
    }[];
  }[];
}

// Updating ONE product's price means finding the right order, the right line
// item, and spreading three levels of arrays/objects correctly, every time:
function updatePrice(state: OrderState, orderId: string, lineItemId: string, price: number) {
  return {
    ...state,
    orders: state.orders.map((order) =>
      order.id !== orderId
        ? order
        : {
            ...order,
            lineItems: order.lineItems.map((item) =>
              item.id !== lineItemId
                ? item
                : { ...item, product: { ...item.product, price } }
            ),
          }
    ),
  };
}

Normalizing flattens that structure into a shape borrowed from relational databases: each entity type gets its own flat lookup table (byId) plus an ordered list of its keys (allIds), and nested relationships become references by id instead of copies of the whole nested object.

normalized shape
interface NormalizedOrders {
  byId: Record<string, { id: string; lineItemIds: string[] }>;
  allIds: string[];
}

interface NormalizedLineItems {
  byId: Record<string, { id: string; quantity: number; productId: string }>;
  allIds: string[];
}

interface NormalizedProducts {
  byId: Record<string, { id: string; name: string; price: number }>;
  allIds: string[];
}

interface OrderState {
  orders: NormalizedOrders;
  lineItems: NormalizedLineItems;
  products: NormalizedProducts;
}

// Updating one product's price is now a single, flat, one-level write —
// no traversal through orders or line items required at all:
productsSlice.reducer(state, action) {
  state.byId[action.payload.id].price = action.payload.price; // Immer handles this safely
}

The payoff compounds as the app grows: a product that appears in ten different orders is stored exactly once, so updating its price updates it everywhere it's referenced, with no risk of one copy going stale while another gets updated. This is precisely the shape RTK Query's cache uses internally (Section 4) — normalization isn't a hand-rolled trick, it's how serious state layers are built.

3. Memoized Selectors with Reselect

Deriving a value from state — a cart total, a filtered list, a count — directly inside a component means recomputing it on every render of that component, even renders triggered by something completely unrelated to the data it depends on. For a cheap calculation that's invisible; for anything that loops over a large normalized collection (Section 2), it becomes real, measurable waste.

terminal
npm install reselect

Reselect's createSelector (also re-exported directly from @reduxjs/toolkit) builds a selector that caches its result and only recomputes when its declared inputs actually change — not on every store update, and not on every render.

features/cart/cartSelectors.ts
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '../../app/store';

// "input" selectors — cheap, no computation, just reach into state
const selectCartItemIds = (state: RootState) => state.cart.lineItems.allIds;
const selectCartItemsById = (state: RootState) => state.cart.lineItems.byId;
const selectProductsById = (state: RootState) => state.products.byId;

// the memoized selector — only recomputes if one of the three inputs above changed
export const selectCartTotal = createSelector(
  [selectCartItemIds, selectCartItemsById, selectProductsById],
  (itemIds, itemsById, productsById) =>
    itemIds.reduce((total, id) => {
      const item = itemsById[id];
      const product = productsById[item.productId];
      return total + product.price * item.quantity;
    }, 0)
);
usage
function CartTotal() {
  const total = useSelector(selectCartTotal);
  return <p>Total: ${total.toFixed(2)}</p>;
}

If notifications state changes elsewhere in the store, CartTotal re-renders (it's subscribed to the whole store via useSelector), but selectCartTotal's expensive .reduce() does not re-run — Reselect checks that selectCartItemIds, selectCartItemsById, and selectProductsById all returned the same references as last time, and returns the cached total instead of recomputing it.

Memoization needs stable references

Reselect compares inputs with ===. If an input selector returns a brand-new array or object literal every call (e.g. state.cart.items.filter(...) inline), the memoization never hits — normalizing your data (Section 2) into flat byId/allIds tables is part of what makes selectors like this one actually cacheable.

4. RTK Query for Server State

RTK Query is Redux Toolkit's own data-fetching and caching layer — the same category of tool as TanStack Query (Weeks 8–9), but built to live inside a Redux store rather than alongside one. You describe endpoints; RTK Query generates hooks, handles caching by argument, and normalizes results into the store automatically, using the same byId-style shape from Section 2 internally.

features/api/productsApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

interface Product {
  id: string;
  name: string;
  price: number;
}

export const productsApi = createApi({
  reducerPath: 'productsApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Product'],
  endpoints: (builder) => ({
    getProducts: builder.query<Product[], void>({
      query: () => '/products',
      providesTags: ['Product'],
    }),
    updateProductPrice: builder.mutation<Product, { id: string; price: number }>({
      query: ({ id, price }) => ({
        url: `/products/${id}`,
        method: 'PATCH',
        body: { price },
      }),
      invalidatesTags: ['Product'], // triggers an automatic refetch of getProducts
    }),
  }),
});

export const { useGetProductsQuery, useUpdateProductPriceMutation } = productsApi;
app/store.ts — RTK Query is wired in like any other slice
import { configureStore } from '@reduxjs/toolkit';
import { productsApi } from '../features/api/productsApi';

export const store = configureStore({
  reducer: {
    [productsApi.reducerPath]: productsApi.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(productsApi.middleware), // powers caching, polling, refetch
});
usage — reads almost identically to Week 8's useQuery
function ProductList() {
  const { data: products, isLoading } = useGetProductsQuery();
  const [updatePrice] = useUpdateProductPriceMutation();

  if (isLoading) return <p>Loading…</p>;

  return (
    <ul>
      {products?.map((p) => (
        <li key={p.id}>
          {p.name} — ${p.price}
          <button onClick={() => updatePrice({ id: p.id, price: p.price + 1 })}>
            Raise price
          </button>
        </li>
      ))}
    </ul>
  );
}

The API surface is deliberately close to useQuery/useMutationisLoading, cache-first reads, automatic refetch-on-invalidate — because RTK Query and TanStack Query are solving the same problem. The real decision isn't which one is "better" in the abstract:

  • Reach for RTK Query when you're already using Redux Toolkit for client state and want server data living in the same store, using the same devtools, the same middleware pipeline, and one mental model for the whole app's state.
  • Reach for TanStack Query when you have no other reason to bring in Redux — it's framework-agnostic (works identically outside React), has zero dependency on a global store, and is the lighter-weight choice if your client state (Week 10) is small enough for Context or Zustand alone.

Pulling in Redux Toolkit purely to get RTK Query, when Zustand or Context already covers your client state, adds a dependency and a store for no real benefit — the honest answer is that this is a genuine tradeoff, not a solved question with one correct tool.

5. Testing Store Logic

A reducer is a pure function — given the same state and action, it always returns the same result, with no rendering, no DOM, and no component involved at all. That makes it one of the cheapest, most reliable things in a React app to unit test, using plain Vitest (Week 14) with no React Testing Library needed.

features/cart/cartSlice.ts — the slice under test
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';

interface LineItem { id: string; productId: string; quantity: number; }
interface CartState { byId: Record<string, LineItem>; allIds: string[]; }

const initialState: CartState = { byId: {}, allIds: [] };

const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    addLineItem: (state, action: PayloadAction<LineItem>) => {
      state.byId[action.payload.id] = action.payload;
      state.allIds.push(action.payload.id);
    },
    removeLineItem: (state, action: PayloadAction<string>) => {
      delete state.byId[action.payload];
      state.allIds = state.allIds.filter((id) => id !== action.payload);
    },
  },
});

export const { addLineItem, removeLineItem } = cartSlice.actions;
export default cartSlice.reducer;
features/cart/cartSlice.test.ts
import { describe, it, expect } from 'vitest';
import cartReducer, { addLineItem, removeLineItem } from './cartSlice';

describe('cartSlice', () => {
  it('adds a line item into byId and allIds', () => {
    const initialState = { byId: {}, allIds: [] };
    const action = addLineItem({ id: 'li1', productId: 'p1', quantity: 2 });

    const state = cartReducer(initialState, action);

    expect(state.byId['li1']).toEqual({ id: 'li1', productId: 'p1', quantity: 2 });
    expect(state.allIds).toEqual(['li1']);
  });

  it('removes a line item from both byId and allIds', () => {
    const withItem = cartReducer(
      { byId: {}, allIds: [] },
      addLineItem({ id: 'li1', productId: 'p1', quantity: 2 })
    );

    const state = cartReducer(withItem, removeLineItem('li1'));

    expect(state.byId['li1']).toBeUndefined();
    expect(state.allIds).toEqual([]);
  });
});

No render(), no store, no provider — just calling the reducer function directly with an input state and an action, and asserting on the output. A selector (Section 3) is tested the same way: build a fake RootState object by hand and assert the selector returns the right derived value.

features/cart/cartSelectors.test.ts
import { describe, it, expect } from 'vitest';
import { selectCartTotal } from './cartSelectors';

describe('selectCartTotal', () => {
  it('sums quantity * price across normalized line items', () => {
    const state = {
      cart: { lineItems: { allIds: ['li1'], byId: { li1: { productId: 'p1', quantity: 2 } } } },
      products: { byId: { p1: { id: 'p1', name: 'Mug', price: 10 } } },
    } as any;

    expect(selectCartTotal(state)).toBe(20);
  });
});

Because reducers and selectors are pure and framework-independent, these tests run in milliseconds and never flake — a strong argument for keeping as much logic as possible inside the store layer rather than scattered across component event handlers, where testing it requires rendering and user interaction (Week 14) instead of a plain function call.

6. Hands-on Exercise

Hands-on

Refactor a flat cart store into a feature slice with normalized data

Take Week 10's single flat cartSlice (an array of items) and rebuild it using everything from this week.

Requirements:

  1. Move the cart's slice, selectors, and any cart-related components into a features/cart/ folder — nothing cart-specific should live outside it.
  2. Change the slice's internal state shape from a flat array of line items to a normalized { byId, allIds } shape (Section 2).
  3. Write addLineItem and removeLineItem reducers that correctly update both byId and allIds.
  4. Write one memoized selector, selectCartTotal, using createSelector (Section 3), that computes the cart total from the normalized state plus a normalized products slice.
  5. Write at least one Vitest test for the slice's reducer (Section 5) that asserts adding and removing a line item produces the correct byId/allIds state, with no rendering involved.
  6. Confirm in the browser (or React DevTools' Redux tab) that a component reading only an unrelated part of the store does not re-render when the cart changes.
Hint

If your selector test needs to fake a whole RootState object, that's expected and fine — it's still a pure-function test with no rendering. Resist the urge to spin up a real store and Provider just to test a selector; that belongs in a component test (Week 14), not here.

Part 2 — Move the product catalog onto RTK Query:

The cart slice manages local write-heavy state; the product catalog is server data — exactly the split RTK Query (Section 4) exists for.

  1. Define a productsApi with createApi, with a getProducts query endpoint replacing however products currently reach the normalized products slice.
  2. Wire the generated useGetProductsQuery() hook directly into the product grid, deleting any manual fetch/useEffect/thunk you had for loading products.
  3. Navigate away from the product grid and back, and confirm — the same way you verified caching in Week 8 — that RTK Query doesn't refire the request within its default cache window.
  4. Add an addProduct mutation endpoint via builder.mutation, tag the getProducts query with providesTags: ['Product'], and set invalidatesTags: ['Product'] on the mutation — the RTK Query equivalent of Week 9's invalidateQueries — and confirm adding a product automatically refreshes the grid with no manual refetch call.
Hint

Don't forget to add productsApi.reducer and productsApi.middleware to your configureStore call — RTK Query's caching, polling, and invalidation all run through that middleware, and a store missing it will make every query hang in a permanent loading state.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does normalizing a nested API response into { byId, allIds } make updates cheaper than updating the nested structure directly?

In a nested shape, updating one deeply-nested field means correctly spreading every level of the structure above it, and an entity duplicated across several parents (a product in ten orders) has to be updated in every copy. Normalizing stores each entity exactly once in a flat lookup table, so updating it is a single one-level write that's automatically reflected everywhere it's referenced by id.

Q2

What does wrapping a derived calculation in createSelector actually prevent, compared to computing it inline in a component?

It prevents the calculation from re-running on every render of the component. A memoized selector only recomputes when its declared input selectors return different values than last time; if the component re-renders for an unrelated reason (some other piece of state changing), the selector returns its cached result instead of redoing the work.

Q3

You're already using Redux Toolkit for client state. Is RTK Query automatically the right choice for server data over TanStack Query?

Not automatically — it's a genuine tradeoff, not a solved question. RTK Query fits naturally if you want server data living in the same store, using the same devtools and middleware as the rest of your app. TanStack Query is framework-agnostic and has no Redux dependency, which can be the better choice even in a Redux app if the server-state layer is meant to stay decoupled from client-state decisions. Neither answer is wrong in general.

Q4

Why can a reducer be unit-tested without rendering anything or using React Testing Library at all?

A reducer is a pure function of (state, action) => newState with no dependency on React, the DOM, or a component tree — calling it directly with a given state and action and asserting on the return value tests it completely. Rendering is only necessary for testing what a user sees and does (Week 14); a reducer's correctness has nothing to do with rendering at all.