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.
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.
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
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.
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).
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.
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.
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.
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)
);
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.
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.
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;
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
});
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/useMutation
— isLoading, 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.
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;
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.
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
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:
- Move the cart's slice, selectors, and any cart-related components into a
features/cart/folder — nothing cart-specific should live outside it. - Change the slice's internal state shape from a flat array of line items to a normalized
{ byId, allIds }shape (Section 2). - Write
addLineItemandremoveLineItemreducers that correctly update bothbyIdandallIds. - Write one memoized selector,
selectCartTotal, usingcreateSelector(Section 3), that computes the cart total from the normalized state plus a normalizedproductsslice. - 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/allIdsstate, with no rendering involved. - 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.
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.
- Define a
productsApiwithcreateApi, with agetProductsquery endpoint replacing however products currently reach the normalizedproductsslice. - Wire the generated
useGetProductsQuery()hook directly into the product grid, deleting any manualfetch/useEffect/thunk you had for loading products. - 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.
- Add an
addProductmutation endpoint viabuilder.mutation, tag thegetProductsquery withprovidesTags: ['Product'], and setinvalidatesTags: ['Product']on the mutation — the RTK Query equivalent of Week 9'sinvalidateQueries— and confirm adding a product automatically refreshes the grid with no manual refetch call.
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?
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?
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?
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?
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.