| name | redux-selector-rules |
| description | Rules for writing Redux Toolkit selectors that don't cause re-renders, recalculation warnings, or runtime loops. Use this skill ANY time you write a useSelector/useAppSelector call, create a selector with createSelector, or derive state in a Redux-connected component. Trigger on: createSelector, useSelector, useAppSelector, selectX naming patterns, Reselect, shallowEqual, memoized selectors, Redux state derivation.
|
Redux Selector Rules
Prevent the most common Redux performance bugs: unnecessary re-renders, Reselect inputStabilityCheck warnings, and render loops on startup.
Core Mechanic
useSelector / useAppSelector uses strict === reference equality. After every dispatched action, it re-runs the selector. If the return value is a new reference, the component re-renders — even if the data is identical.
- Primitives (string, number, boolean): Safe —
=== compares by value.
- Objects and arrays: Dangerous —
[] !== [] and {} !== {} are always true. Any selector returning a new object/array reference on every call forces a re-render on every dispatch.
The Rules
Rule 1: Never use default values in selectors
This is the #1 source of bugs. ?? null, ?? [], ?? {}, ?? "" create a new value every invocation when the source is undefined. This causes infinite re-render loops on startup and triggers Reselect stability warnings.
export const selectAgentMessages = createSelector(
[selectAgentById],
(record) => record?.messages ?? [],
);
export const selectAgentMessages = createSelector(
[selectAgentById],
(record) => record?.messages,
);
Rule 2: Never double-default in the component
If the selector returns the raw value, do not add a default in the component. Same new-reference problem, different location.
const messages = useAppSelector(state => selectAgentMessages(state, id)) ?? [];
const messages = useAppSelector(state => selectAgentMessages(state, id));
if (!messages) return <MessagesSkeleton />;
Rule 3: Handle undefined at the render boundary
The component handles missing data — not the selector. In priority order:
next/dynamic + skeleton — component doesn't load until data exists. Reduces bundle size, eliminates wasted renders, prevents layout shift when the skeleton matches the component dimensions exactly.
- Early return with skeleton —
if (!data) return <Skeleton />;
- Conditional render —
{data && <Component data={data} />}
const AgentPanel = dynamic(() => import('./AgentPanel'), {
loading: () => <AgentSkeleton />,
});
const agent = useAppSelector(state => selectAgentById(state, id));
if (!agent) return <AgentSkeleton />;
return <AgentPanel agent={agent} />;
Skeleton design rule: Skeletons must be pixel-identical in dimensions to the loaded component. A skeleton that shifts layout on load is worse than no skeleton.
Rule 4: Input selectors extract, result functions transform
Reselect's inputStabilityCheck runs input selectors twice in dev mode. An input selector returning a different reference on the second call triggers a warning.
const selectCompleted = createSelector(
[state => state.todos.filter(t => t.completed)],
(completed) => completed.length,
);
const selectCompleted = createSelector(
[state => state.todos],
(todos) => todos.filter(t => t.completed).length,
);
Input selectors: plain lookups only — state => state.some.slice.
Result function: all .filter(), .map(), .reduce(), aggregation, and derivation.
Rule 5: Never pass state => state as an input selector
Root state reference changes on every action. This forces recalculation on every dispatch.
Rule 6: Multiple primitives > one object
const { name, status } = useAppSelector(state => ({
name: state.agent.name,
status: state.agent.status,
}));
const name = useAppSelector(state => state.agent.name);
const status = useAppSelector(state => state.agent.status);
If an object is unavoidable, pass shallowEqual as the second argument to useAppSelector. But prefer separate calls for primitives.
Rule 7: Parameterized selectors need factory functions when shared across components
createSelector has a cache size of 1. Multiple components calling the same selector with different arguments break memoization.
const makeSelectAgentById = () =>
createSelector(
[state => state.agents.entities, (_state, id: string) => id],
(entities, id) => entities[id],
);
const selectAgent = useMemo(makeSelectAgentById, []);
const agent = useAppSelector(state => selectAgent(state, agentId));
Refactoring Selectors: Full Codebase Sweep Required
This is the most important section for refactors. When you change a selector — especially removing a default value (?? [], ?? null) or changing its return type — the type changes from T to T | undefined. You must find and update every consumer before the refactor is complete. Missing a single usage produces a runtime crash, not a build error.
Mandatory refactor steps
- Search the entire codebase for all usages of the selector name (e.g.,
selectAgentMessages).
- Update every component — add an early return, skeleton, or guard before any property access or iteration.
- Check chained selectors — if the selector is an input to another
createSelector, the downstream result function now receives T | undefined and must handle it.
- Check non-component usages — thunks, middleware, sagas, utils that call the selector also need undefined handling.
- Update the selector's TypeScript return type so the compiler enforces the change.
const items = messages.map(m => m.text);
if (!messages) return <Skeleton />;
const items = messages.map(m => m.text);
A refactor is not complete until every consumer is updated.
Refactor as an upgrade opportunity
Every time you touch a selector's consumers, treat it as a chance to improve the component:
- Replace inline null guards with
next/dynamic lazy loading
- Replace ad-hoc loading states with purpose-built skeleton components
- Ensure skeletons are dimensionally identical to prevent layout shift
- Split
useAppSelector calls that return objects into separate primitive calls
Fetch Status: The Authoritative Source for "What Data This Record Has"
Never infer data availability from field presence (e.g., checking _loadedFields.has("messages")). A field can arrive via any number of narrower fetches and will produce a false positive. The thunk is the only code that knows exactly what it fetched — so the thunk is where readiness is declared.
The pattern
The slice holds a _fetchStatus string on every record. Thunks set it after a successful fetch. Selectors read it and return booleans for each UI use case.
type AgentFetchStatus =
| "list"
| "execution"
| "customExecution"
| "full"
| "versionSnapshot"
The slice enforces one-directional precedence: status only upgrades, never downgrades. full will overwrite execution; versionSnapshot is the ceiling and cannot be overwritten by anything.
One boolean selector per UI use case
Build on the existing selectAgentFetchStatus primitive. Each selector is a pure comparison — safe with useAppSelector.
export const selectAgentReadyForDisplay = createSelector(
[selectAgentFetchStatus],
(status): boolean =>
status === "list" || status === "full" || status === "versionSnapshot",
);
export const selectAgentReadyForExecution = createSelector(
[selectAgentFetchStatus],
(status): boolean =>
status === "execution" || status === "customExecution" ||
status === "full" || status === "versionSnapshot",
);
export const selectAgentReadyForCustomExecution = createSelector(
[selectAgentFetchStatus],
(status): boolean =>
status === "customExecution" || status === "full" || status === "versionSnapshot",
);
export const selectAgentReadyForBuilder = createSelector(
[selectAgentFetchStatus],
(status): boolean => status === "full" || status === "versionSnapshot",
);
export const selectAgentReadyForVersionDisplay = createSelector(
[selectAgentFetchStatus],
(status): boolean => status === ,
);
How the thunk sets it
dispatch(setAgentFetchStatus({ id, status: "list" }));
dispatch(upsertAgent(dbRowToAgentDefinition(data)));
Using the boolean selector in a component
const isReadyForBuilder = useAppSelector((state) =>
selectAgentReadyForBuilder(state, agentId),
);
useEffect(() => {
if (!isReadyForBuilder) dispatch(fetchFullAgent(agentId));
}, [agentId]);
if (!isReadyForBuilder) return <AgentBuilderSkeleton />;
❌ Never do this
const isReady = record?._loadedFields.has("messages") ?? false;
const [isLoading, setIsLoading] = useState(false);
dispatch(fetchFullAgent(id)).finally(() => setIsLoading(false));
Quick Reference
| Return type | Safe? | Fix |
|---|
| Primitive | ✅ | None |
| Existing object ref from state | ✅ | None |
.filter() / .map() result | ❌ new array | Wrap in createSelector |
?? [] / ?? {} / ?? null | ❌ new ref when undefined | Remove default, guard in component |
{ a: state.a, b: state.b } | ❌ new object | Separate useAppSelector calls or shallowEqual |
Debugging
If you see "Selector returned a different result when called with the same parameters":
- Check for
??, ||, or default values in the selector — remove them.
- Check for
.filter(), .map(), or object construction in input selectors — move to result function.
- Use
selector.recomputations() and selector.dependencyRecomputations() to trace what's recalculating.
Real-World Example: Replacing a Hook with Selectors
The Anti-Pattern: a hook that manages "derived display state"
A common mistake is reaching for useState + useEffect when all you need is a selector. This hook existed to pick the best available title for an agent execution instance:
export function useAnimatedTitle(instanceId: string) {
const resolvedTitle = useInstanceTitle(instanceId);
const conversationTitle = useAppSelector(selectConversationTitle(instanceId));
const [displayTitle, setDisplayTitle] = useState(resolvedTitle ?? "Agent");
const prevRef = useRef<string | null>(null);
useEffect(() => {
if (conversationTitle && conversationTitle !== prevRef.current) {
prevRef.current = conversationTitle;
setDisplayTitle(conversationTitle);
}
}, [conversationTitle]);
useEffect(() => {
if (!conversationTitle && resolvedTitle) {
setDisplayTitle(resolvedTitle);
}
}, [resolvedTitle, conversationTitle]);
return displayTitle;
}
Problems:
- Two
useEffect calls managing state that is already in Redux
useRef tracking a "previous value" that Redux already tracks
- Every dispatch causes the outer selector to run, then maybe triggers a state update, causing a second render
resolvedTitle ?? "Agent" in useState initial value: if resolvedTitle is undefined on first render, displayTitle starts as "Agent" and stays there until the next effect fires — a stale render
The Correct Pattern: tiered inline selectors
The same logic as a set of plain selectors — all primitive reads, no new references, single render per change:
export const selectInstanceAgentName =
(instanceId: string) =>
(state: RootState): string | undefined => {
const agentId = state.executionInstances.byInstanceId[instanceId]?.agentId;
if (!agentId) return undefined;
return state.agentDefinition.agents?.[agentId]?.name || undefined;
};
export const selectInstanceTitle =
(instanceId: string) =>
(state: RootState): string | undefined => {
const instance = state.executionInstances.byInstanceId[instanceId];
if (!instance) return undefined;
if (instance.shortcutId) {
const label = state.agentShortcut?.[instance.shortcutId]?.label;
if (label) return label;
}
(instance.) {
name = state..?.[instance.]?.;
(name) name;
}
;
};
=
() =>
(: ): {
conversationTitle =
state..[instanceId]?.;
(conversationTitle) conversationTitle;
instance = state..[instanceId];
(!instance) ;
(instance.) {
label = state.?.[instance.]?.;
(label) label;
}
(instance.) {
name = state..?.[instance.]?.;
(name) name;
}
;
};
Why this works:
- Each read is a primitive string —
=== comparison catches all changes
- The
"Agent" fallback is a string literal, not a new reference — it's always === "Agent"
- No
useState, no useEffect, no useRef — zero extra renders
- Each tier is independently usable: pre-execution UI calls Tier 1 (agent name only), title bars call Tier 3
In the component:
const { displayTitle } = useAnimatedTitle(instanceId);
const displayTitle = useAppSelector(selectInstanceDisplayTitle(instanceId));