Concept
A selector is just a function that takes the store's state and returns some piece (or derived computation) of it: (state) => state.counter.value. On its own that's trivial, the real story is when a component re-renders based on a selector's result, and how to avoid expensive recomputation on every single dispatch.
useSelector: per-component subscriptions with reference-equality skipping
import { useSelector } from "react-redux";
function CounterDisplay() {
const count = useSelector((state) => state.counter.value);
return <span>{count}</span>;
}
function UserDisplay() {
const name = useSelector((state) => state.user.name);
return <span>{name}</span>;
}Unlike the store's raw subscribe (covered in Store, which fires on every dispatch with no filtering), useSelector runs its selector after each dispatch and compares the new result to the previous one using reference equality by default, only re-rendering the component if that specific result actually changed. Dispatching an action that only touches state.user never re-renders CounterDisplay, since state.counter.value's reference (a primitive number here, always compared by value) is unaffected, this directly builds on the per-slice reference-stability confirmed in Actions & Reducers.
The problem useSelector alone doesn't solve: derived computations
function ActiveItemsList() {
const activeItems = useSelector((state) => state.items.filter((i) => i.active)); // ❌ new array EVERY call
return <ul>{activeItems.map((i) => <li key={i.id}>{i.name}</li>)}</ul>;
}.filter() constructs a brand-new array on every single invocation, even when state.items hasn't changed at all. Since useSelector's default comparison is reference equality, this component re-renders on every dispatched action in the entire app, not just ones that actually change items, the exact same category of bug as Zustand's object-selector gotcha, just showing up through a different library's API.
createSelector: memoizing the derived computation itself
import { createSelector } from "reselect";
const selectItems = (state) => state.items;
const selectFilter = (state) => state.filter;
const selectFilteredItems = createSelector(
[selectItems, selectFilter], // INPUT selectors
(items, filter) => items.filter((i) => i.includes(filter)) // RESULT function
);const selectFilteredItems = createSelector([selectItems, selectFilter],(items, filter) => items.filter((i) => i.includes(filter)));selectFilteredItems(state1); // first call
The first call always computes, there's nothing cached yet. The result function runs, and its output is cached alongside the input values that produced it.
Confirmed by running this exact selector across three calls: (1) an initial call, always computes, nothing cached yet; (2) a call against a genuinely new state object where items and filter are unchanged by reference, the result function is skipped entirely, the cached result is returned directly; (3) a call where filter genuinely changed, the result function reruns. Total: 2 real recomputations across 3 calls. createSelector doesn't compare the whole state object (which is a new reference on every dispatch anyway), it compares each input selector's individual output against last time, and only reruns the (potentially expensive) result function if at least one of those specific outputs differs.
Composing useSelector with createSelector
function ActiveItemsList() {
const activeItems = useSelector(selectFilteredItems); // now genuinely memoized
return <ul>{activeItems.map((i) => <li key={i.id}>{i.name}</li>)}</ul>;
}This is the fix for the earlier bug: selectFilteredItems only produces a new array reference when items or filter actually changed, so useSelector's reference-equality check correctly skips re-rendering ActiveItemsList on unrelated dispatches, and the (potentially expensive) .filter() call itself only runs when its actual inputs changed, not on every render attempt.
Try It
Predict the outcome before checking the solution.
const selectA = (state) => state.a;
const selectB = (state) => state.b;
let computeCount = 0;
const selectSum = createSelector([selectA, selectB], (a, b) => {
computeCount++;
return a + b;
});
const state1 = { a: 1, b: 2, unrelated: "x" };
selectSum(state1);
const state2 = { ...state1, unrelated: "y" };
What does computeCount log?
Solution
2.
The first call always computes (nothing cached yet), computeCount becomes 1. The second call passes a genuinely new state2 object, but selectA(state2) and selectB(state2) return the exact same values as before (1 and 2), since only unrelated changed, a and b are untouched. createSelector compares those specific input-selector outputs, sees no change, and returns the cached sum WITHOUT calling the result function, computeCount stays at 1. The third call has a genuinely different a (5 instead of 1), so the input selectors' outputs differ from what's cached, the result function reruns, and computeCount becomes 2.
Implement It Yourself
Build a minimal version of createSelector, to see exactly how input-comparison-based memoization works:
function myCreateSelector(inputSelectors, resultFn) {
let lastInputs = null;
let lastResult;
return function selector(state) {
const currentInputs = inputSelectors.map((s) => s(state));
const inputsChanged =
lastInputs === null ||
currentInputs.some((value, i) => value !== lastInputs[i]); // reference-equality check, PER input
if (inputsChanged) {
lastResult = resultFn(...
The key difference from a naive "cache the whole state" approach: this compares each input selector's output, individually, by reference, not the top-level state object (which the reducer always returns as a new reference on any dispatch, per Actions & Reducers). That per-input comparison is exactly what lets createSelector correctly skip recomputation even though it's called against a technically-new state object every time.
Under the Hood
This directly extends Store's subscribe-fires-on-everything behavior, useSelector is the layer that adds per-component filtering, and createSelector adds a second, independent layer of filtering specifically for expensive derived computations. The reference-stability confirmed in Actions & Reducers (unchanged slices keep their exact reference) is what makes both of these memoization layers actually work, without it, every input selector's output would appear "changed" on every dispatch, and none of this caching would have any effect.
Common Mistakes
1. Creating a createSelector-wrapped selector INSIDE a component
function BadComponent() {
const selectFilteredItems = createSelector( // ❌ new selector instance every render!
[selectItems, selectFilter],
(items, filter) => items.filter((i) => i.includes(filter))
);
const filtered = useSelector(selectFilteredItems);
// ...
}createSelector returns a memoized function with its own internal cache, creating that function fresh inside the component body means a brand-new, empty cache every single render, defeating memoization entirely. Selectors should be defined once, at module scope, outside any component.
2. Using an inline arrow function as a useSelector argument for a derived value
const activeItems = useSelector((state) => state.items.filter((i) => i.active)); // ❌ same issue as above, no memoization at allWithout wrapping in createSelector, every call to this inline selector constructs a fresh array, useSelector's reference-equality check has nothing to work with.
3. Assuming createSelector compares the whole state object
// Misconception: "createSelector caches based on whether state changed"It doesn't, it explicitly ignores the top-level state reference (which changes on every dispatch regardless) and only compares each individual input selector's output. This is precisely why an unrelated field changing elsewhere in state doesn't invalidate the cache.
Best Practices
- Define selectors (both plain and
createSelector-wrapped) at module scope, outside components, never construct a memoized selector fresh inside a render. - Wrap any derived/computed selector in
createSelector, filtering, sorting, mapping, aggregating, anything that would otherwise construct a new reference on every call. - Keep input selectors narrow and specific (
state.items,state.filter) rather than passing the wholestateobject as a single input, narrower inputs mean the cache is only invalidated by changes that are actually relevant.
Performance Tips
createSelector's default memoization only remembers the most recent call's inputs, calling it with alternating different inputs on every render (e.g. from multiple component instances with different props) causes it to recompute every time, defeating the cache. Reselect supports per-instance memoized selectors for exactly this case.- Memoized selectors move expensive computation (filtering large lists, aggregating) out of the render path entirely for the common case where relevant inputs haven't changed, this compounds well with
useSelector's own re-render skipping, giving two independent layers of avoided work.
