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.
// ❌ WRONG — new reference every call when record is undefined
export const selectAgentMessages = createSelector(
[selectAgentById],
(record) => record?.messages ?? [],
);
// ✅ CORRECT — return as-is, including undefined
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.
// ❌ WRONG — ?? [] creates a new array ref every render
const messages = useAppSelector(state => selectAgentMessages(state, id)) ?? [];
// ✅ CORRECT — handle undefined at the render boundary
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} />}
// ✅ GOLD STANDARD — lazy load, show skeleton until data exists
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.
// ❌ WRONG — filter in input creates new ref every call
const selectCompleted = createSelector(
[state => state.todos.filter(t => t.completed)],
(completed) => completed.length,
);
// ✅ CORRECT — extract in input, transform in result
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
// ❌ WRONG — new object every call
const { name, status } = useAppSelector(state => ({
name: state.agent.name,
status: state.agent.status,
}));
// ✅ CORRECT — two stable primitive selectors
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.
// ✅ Factory — each component instance gets its own memoized selector
const makeSelectAgentById = () =>
createSelector(
[state => state.agents.entities, (_state, id: string) => id],
(entities, id) => entities[id],
);
// In component
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.
// Before: selector returned [] — every consumer assumed array
// After: selector returns undefined
// ❌ CRASH — .map() on undefined
const items = messages.map(m => m.text);
// ✅ Guard first
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.
// types.ts — fetch status levels in precedence order
type AgentFetchStatus =
| "list" // name, description, access metadata (card display)
| "execution" // + variableDefinitions, contextSlots
| "customExecution"// + settings, tools, model
| "full" // full SELECT * (builder / editor)
| "versionSnapshot"// full version snapshot (read-only)
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.
// ✅ Card display — needs name + description
export const selectAgentReadyForDisplay = createSelector(
[selectAgentFetchStatus],
(status): boolean =>
status === "list" || status === "full" || status === "versionSnapshot",
);
// ✅ Minimal execution — variableDefinitions + contextSlots
export const selectAgentReadyForExecution = createSelector(
[selectAgentFetchStatus],
(status): boolean =>
status === "execution" || status === "customExecution" ||
status === "full" || status === "versionSnapshot",
);
// ✅ Custom execution — adds settings, tools, model
export const selectAgentReadyForCustomExecution = createSelector(
[selectAgentFetchStatus],
(status): boolean =>
status === "customExecution" || status === "full" || status === "versionSnapshot",
);
// ✅ Builder / editor — full SELECT * required
export const selectAgentReadyForBuilder = createSelector(
[selectAgentFetchStatus],
(status): boolean => status === "full" || status === "versionSnapshot",
);
// ✅ Version panel — snapshot only
export const selectAgentReadyForVersionDisplay = createSelector(
[selectAgentFetchStatus],
(status): boolean => status === "versionSnapshot",
);
How the thunk sets it
// Partial fetch (list, execution, customExecution): dispatch the action explicitly
dispatch(setAgentFetchStatus({ id, status: "list" }));
// Full fetch: upsertAgent sets it automatically based on record.isVersion
dispatch(upsertAgent(dbRowToAgentDefinition(data)));
// → sets "full" for live agents, "versionSnapshot" for version records
Using the boolean selector in a component
// ✅ Gate the builder on the authoritative status — not field presence
const isReadyForBuilder = useAppSelector((state) =>
selectAgentReadyForBuilder(state, agentId),
);
useEffect(() => {
if (!isReadyForBuilder) dispatch(fetchFullAgent(agentId));
}, [agentId]);
if (!isReadyForBuilder) return <AgentBuilderSkeleton />;
❌ Never do this
// ❌ Field presence is not authoritative — messages could have arrived
// from a different, narrower fetch (e.g. a messages-only endpoint)
const isReady = record?._loadedFields.has("messages") ?? false;
// ❌ Local state duplicating what the slice already tracks
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:
// ❌ WRONG — useEffect/useState for pure state derivation
export function useAnimatedTitle(instanceId: string) {
const resolvedTitle = useInstanceTitle(instanceId); // another hook
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:
// ✅ Tier 1 — agent name only (string | undefined)
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;
};
// ✅ Tier 2 — shortcut label → agent 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;
}
if (instance.agentId) {
const name = state.agentDefinition.agents?.[instance.agentId]?.name;
if (name) return name;
}
return undefined;
};
// ✅ Tier 3 — conversationTitle → shortcutLabel → agentName → "Agent"
// Always returns a string. Use for title bars that must never be empty.
export const selectInstanceDisplayTitle =
(instanceId: string) =>
(state: RootState): string => {
const conversationTitle =
state.instanceConversationHistory.byInstanceId[instanceId]?.title;
if (conversationTitle) return conversationTitle;
const instance = state.executionInstances.byInstanceId[instanceId];
if (!instance) return "Agent";
if (instance.shortcutId) {
const label = state.agentShortcut?.[instance.shortcutId]?.label;
if (label) return label;
}
if (instance.agentId) {
const name = state.agentDefinition.agents?.[instance.agentId]?.name;
if (name) return name;
}
return "Agent";
};
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:
// Before — hook with hidden re-render complexity
const { displayTitle } = useAnimatedTitle(instanceId);
// After — one selector call, one render per value change
const displayTitle = useAppSelector(selectInstanceDisplayTitle(instanceId));