Chapter 44 โ Selectors, Normalised State & Performance
๐ Definitions
- Selector โ a function
(state: RootState) => valuethat reads or derives data from the store. - Memoized selector โ created with
createSelector(Reselect); it only recomputes when its inputs change and otherwise returns the same reference. - Normalised state โ storing collections as
{ ids: string[], entities: Record<id, item> }instead of nested arrays, like tables in a database. createEntityAdapterโ RTK helper that generates normalised state, CRUD reducers and selectors.
๐ Why Selectors Matter for Performance
useSelector re-runs your selector after every dispatched action and re-renders the component if the result is not === the previous one.
// โ New array every time โ component re-renders on EVERY action in the app
const done = useAppSelector((s) => s.todos.items.filter((t) => t.done));
// โ New object every time โ same problem
const { name, email } = useAppSelector((s) => ({ name: s.auth.user?.name, email: s.auth.user?.email }));
// โ
Select primitives / existing references separately
const name = useAppSelector((s) => s.auth.user?.name);
const email = useAppSelector((s) => s.auth.user?.email);
// โ
Or memoize derived data
const done = useAppSelector(selectDoneTodos);๐ป Code Example โ createSelector
// features/cart/cartSelectors.ts
import { createSelector } from "@reduxjs/toolkit";
import type { RootState } from "../../app/store";
// Input selectors: cheap, return existing references
const selectItems = (s: RootState) => s.cart.items;
const selectCoupon = (s: RootState) => s.cart.couponCode;
export const selectSubtotal = createSelector([selectItems], (items) =>
items.reduce((sum, i) => sum + i.price * i.qty, 0),
);
const DISCOUNTS: Record<string, number> = { SAVE10: 0.1, WELCOME20: 0.2 };
// Selectors compose: the output of one is the input of another
export const selectCartTotal = createSelector([selectSubtotal, selectCoupon], (subtotal, coupon) => {
const rate = coupon ? DISCOUNTS[coupon] ?? 0 : 0;
return Math.round(subtotal * (1 - rate));
});
export const selectExpensiveItems = createSelector([selectItems], (items) =>
items.filter((i) => i.price > 1000), // same array ref until items change
);๐ป Code Example โ Selectors with Arguments
// Pass extra arguments through as another input selector
export const selectTodosByStatus = createSelector(
[(s: RootState) => s.todos.items, (_s: RootState, status: "all" | "done" | "open") => status],
(items, status) => (status === "all" ? items : items.filter((t) => (status === "done" ? t.done : !t.done))),
);
// Component
const visible = useAppSelector((s) => selectTodosByStatus(s, filter));A
createSelectorinstance caches only the last argument set (Reselect 5 usesweakMapMemoize, which caches more, but don't rely on it for per-item selectors). If many list rows each need their own memoized selector, create one per component withuseMemo(() => makeSelectItemById(), []).
๐ Why Normalise?
Nested / duplicated (โ):
{
"posts": [
{ "id": "p1", "author": { "id": "u1", "name": "Asha" }, "comments": [ { "id": "c1", "author": { "id": "u1", "name": "Asha" } } ] }
]
}Renaming user u1 means finding and updating every copy. Lookups by id are O(n).
Normalised (โ ):
{
"users": { "ids": ["u1"], "entities": { "u1": { "id": "u1", "name": "Asha" } } },
"posts": { "ids": ["p1"], "entities": { "p1": { "id": "p1", "authorId": "u1", "commentIds": ["c1"] } } },
"comments": { "ids": ["c1"], "entities": { "c1": { "id": "c1", "authorId": "u1" } } }
}Each item exists once; lookups are O(1); updating one entity doesn't create new references for unrelated ones โ fewer re-renders.
๐ป Code Example โ createEntityAdapter
// features/todos/todosSlice.ts
import { createEntityAdapter, createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type { RootState } from "../../app/store";
import { fetchTodos } from "./todosThunks";
export interface Todo { id: string; title: string; done: boolean; createdAt: string }
const todosAdapter = createEntityAdapter<Todo>({
sortComparer: (a, b) => b.createdAt.localeCompare(a.createdAt), // keep ids sorted (newest first)
});
// { ids: [], entities: {}, status: "idle" }
const initialState = todosAdapter.getInitialState({ status: "idle" as "idle" | "loading" | "failed" });
const todosSlice = createSlice({
name: "todos",
initialState,
reducers: {
todoAdded: todosAdapter.addOne,
todoRemoved: todosAdapter.removeOne,
todoUpdated: todosAdapter.updateOne, // payload: { id, changes: Partial<Todo> }
todoToggled(state, action: PayloadAction<string>) {
const todo = state.entities[action.payload];
if (todo) todo.done = !todo.done;
},
allCompletedCleared(state) {
const doneIds = Object.values(state.entities).filter((t) => t?.done).map((t) => t!.id);
todosAdapter.removeMany(state, doneIds);
},
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (s) => { s.status = "loading"; })
.addCase(fetchTodos.fulfilled, (s, a) => {
s.status = "idle";
todosAdapter.setAll(s, a.payload); // replace all
});
},
});
export const { todoAdded, todoRemoved, todoUpdated, todoToggled, allCompletedCleared } = todosSlice.actions;
export default todosSlice.reducer;
// Generated, memoized selectors bound to this slice's location in RootState
export const {
selectAll: selectAllTodos, // Todo[] in sorted order
selectById: selectTodoById, // (state, id) => Todo | undefined
selectIds: selectTodoIds,
selectTotal: selectTodoCount,
} = todosAdapter.getSelectors<RootState>((s) => s.todos);Adapter CRUD methods: addOne/addMany, setOne/setMany/setAll, updateOne/updateMany, upsertOne/upsertMany, removeOne/removeMany/removeAll.
๐ป Code Example โ List Rendering Pattern (Ids Down, Select by Id)
import { memo } from "react";
export function TodoList() {
const ids = useAppSelector(selectTodoIds); // only changes when items are added/removed/reordered
return <ul>{ids.map((id) => <TodoRow key={id} id={id} />)}</ul>;
}
const TodoRow = memo(function TodoRow({ id }: { id: string }) {
const todo = useAppSelector((s) => selectTodoById(s, id)); // re-renders only when THIS todo changes
const dispatch = useAppDispatch();
if (!todo) return null;
return (
<li>
<label>
<input type="checkbox" checked={todo.done} onChange={() => dispatch(todoToggled(id))} />
{todo.title}
</label>
</li>
);
});Toggling one todo now re-renders one row, not the whole list of 1,000.
๐ป Code Example โ shallowEqual for Small Objects
import { shallowEqual } from "react-redux";
// Re-render only if any of these top-level values change
const { status, error } = useAppSelector((s) => ({ status: s.users.status, error: s.users.error }), shallowEqual);๐ Performance Checklist
| Problem | Fix |
|---|---|
| Component re-renders on every action | Selector returns new array/object โ memoize or select primitives |
| Whole list re-renders on one item change | Normalise + pass ids + select by id in memo rows |
| Expensive derived data recomputed | createSelector |
| Huge state copied on each update | Normalise; keep big immutable blobs out of hot paths |
| Store holds data nobody uses | Use RTK Query (auto cache removal) for server data |
| Dev-mode slowness | Immutability/serialisability checks are dev-only; tune warnAfter or exclude huge slices |
๐ Real-World Impact
- A dashboard with 2,000 rows went from re-rendering every row on each WebSocket tick to re-rendering only changed rows after switching to ids +
selectById. - Memoized totals mean a cart badge doesn't recalculate on unrelated actions like opening a modal.
๐ฏ Likely Interview Questions
- What is a selector and why use one? โ Encapsulates state shape, reusable, enables memoization.
- How does
createSelectormemoize? โ Compares input-selector results by reference; if unchanged returns cached output. - Why does
useSelector(s => s.items.filter(...))hurt performance? โ Returns a new array each run โ===fails โ re-render every dispatch. - What is normalised state? โ
ids+entitiesmap; single copy of each entity; O(1) lookup. - What does
createEntityAdaptergive you? โ Normalised initial state, CRUD reducers, sorted ids, memoized selectors. - How do you avoid re-rendering a whole list when one item changes? โ Pass ids to
memorows that select their own entity. - When would you use
shallowEqual? โ Selecting a small object of several fields without memoizing.
โ RTK Query | Index | Next: Middleware, Testing & Alternatives โ