Skip to content
Back to skills

Redux Selector Rules

ASecurity

Redux Toolkit selector rules against re-renders, recalculation warnings, and render loops. Use when writing useSelector/useAppSelector or createSelector, deriving state in a connected component, or seeing 'Selector returned a different result when called with the same parameters'.

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 3, 2026
code-qualitytypescriptgodebuggingrefactoringperformance

Security analysis

A100/100

Scanned October 3, 2026

npx -y skills add armanisadeghi/ai-matrx --skill redux-selector-rules --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Redux Selector Rules?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Redux Selector Rules
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/armanisadeghi-redux-selector-rules/badge)](https://www.skillsdirectory.com/skills/armanisadeghi-redux-selector-rules)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: redux-selector-rules
description: "Redux Toolkit selector rules against re-renders, recalculation warnings, and render loops. Use when writing useSelector/useAppSelector or createSelector, deriving state in a connected component, or seeing 'Selector returned a different result when called with the same parameters'."
---

# 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.

```ts
// ❌ 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.

```ts
// ❌ 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:

1. **`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.
2. **Early return with skeleton** — `if (!data) return <Skeleton />;`
3. **Conditional render** — `{data && <Component data={data} />}`

```tsx
// ✅ 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.

```ts
// ❌ 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

```ts
// ❌ 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.

```ts
// ✅ 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

1. **Search the entire codebase** for all usages of the selector name (e.g., `selectAgentMessages`).
2. **Update every component** — add an early return, skeleton, or guard before any property access or iteration.
3. **Check chained selectors** — if the selector is an input to another `createSelector`, the downstream result function now receives `T | undefined` and must handle it.
4. **Check non-component usages** — thunks, middleware, sagas, utils that call the selector also need undefined handling.
5. **Update the selector's TypeScript return type** so the compiler enforces the change.

```ts
// 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.

```ts
// 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`.

```ts
// ✅ 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

```ts
// 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

```tsx
// ✅ 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

```ts
// ❌ 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"*:

1. Check for `??`, `||`, or default values in the selector — remove them.
2. Check for `.filter()`, `.map()`, or object construction in input selectors — move to result function.
3. 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:

```ts
// ❌ 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:

```ts
// ✅ 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:**

```tsx
// 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));
```

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…