“If you want to destroy my sweater, pull this thread as I walk away.” — Weezer, Undone (The Sweater Song)

TL;DR for Busy People

The problem: Reducers that replace entire state branches instead of merging cause race conditions. Data disappears. The bug only appears with concurrent requests. Your tests pass. Users complain.

The fix: Update specific keys, not entire objects. Replace state.data = {} with state.data[parentId] = {}.

The better fix: Use RTK Query. It handles caching, merging, and deduplication automatically. You cannot write this bug because you do not write reducer logic.

The pattern: Normalize your state with byId and byParentId maps. This creates isolation boundaries. One response cannot wipe another.

ScenarioWhat Happens
state.data = action.payloadReplaces everything. Race conditions.
state.data[key] = action.payloadUpdates one key. Safe.
RTK QueryAutomatic cache management. No bugs.

Skip to “The Normalized State Pattern” if you want the solution.


The Problem: Your Reducer Is a Bull in a China Shop

Picture a kitchen with multiple chefs. Chef A prepares appetizers for table 5. Chef B makes entrees for table 12. They both use the same prep station.

Chef A finishes appetizers, writes “Table 5 ready” on the order board, and clears the ENTIRE prep station. Chef B’s half-cooked entree? Gone. The ingredients Chef B was using? Wiped clean. Chef B returns to an empty station, confused.

This is what your reducer does when it replaces the entire state branch:

.addCase(fetchItems.fulfilled, (state, action) => {
  const { parentId } = action.meta.arg;
  state.data = action.payload.reduce((acc, item) => {
    acc[parentId] = acc[parentId] || {};
    acc[parentId][item.id] = item;
    return acc;
  }, {});  // ← Fresh empty object. Everything else is gone.
});

The {} is the problem. You start with an empty object. Everything that was in state.data disappears. Parent A’s data? Gone. Parent B’s data? Erased. Only the current payload survives.


The Kitchen Analogy: Mise en Place

A well-organized kitchen uses mise en place (French for “everything in its place”). Each station has labeled containers. Appetizers go in one section. Entrees in another. Desserts have their own space.

When Chef A finishes appetizers for table 5, they put the dishes in the “Table 5 Appetizers” container. They do NOT throw out the entire mise en place and rebuild it. The “Table 12 Entrees” container stays untouched.

This is normalized state structure:

type SliceState = {
  byId: Record<string, Item | undefined>;
  byParentId: Record<string, {
    ids: string[];
    isLoading: boolean;
  } | undefined>;
};

Each parent gets its own container. Updating parent A’s data does NOT affect parent B’s data. They are isolated.

The Race Condition in the Kitchen

Here’s what happens without isolation:

Time 0ms:   Chef A starts appetizers for table 5
Time 10ms:  Chef B starts entrees for table 12
Time 200ms: Chef A finishes, clears the prep station, writes "Table 5: [appetizers]"
Time 250ms: Chef B finishes, clears the prep station, writes "Table 12: [entrees]"
            Table 5's appetizers are gone. The order board only shows table 12.

In Redux terms:

Time 0ms:   dispatch(fetchItems(parentA))
Time 10ms:  dispatch(fetchItems(parentB))
Time 200ms: Response for parentA arrives → state.data = { parentA: [...] }
Time 250ms: Response for parentB arrives → state.data = { parentB: [...] }
            parentA data is GONE.

The last response wins. Everything before it is erased. Users report data disappearing. You check logs. Nothing is wrong. The bug is timing-dependent.


The Fix: Label Your Containers

Stop clearing the entire prep station. Update only the container you need.

// ❌ WRONG: Clears the entire station
.addCase(fetchItems.fulfilled, (state, action) => {
  state.data = action.payload.reduce((acc, item) => {
    acc[item.id] = item;
    return acc;
  }, {});
});

// ✅ CORRECT: Updates only the relevant container
.addCase(fetchItems.fulfilled, (state, action) => {
  const { parentId } = action.meta.arg;
  state.data[parentId] = action.payload.reduce((acc, item) => {
    acc[item.id] = item;
    return acc;
  }, {});
});

The difference: state.data[parentId] = ... vs state.data = ...

One word. One bracket. It prevents data loss.


The Normalized State Pattern

Design your state like a kitchen with labeled stations:

type SliceState = {
  byId: Record<string, Item | undefined>;
  byParentId: Record<string, {
    ids: string[];
    isLoading: boolean;
    error?: SerializedError;
  } | undefined>;
};

const initialState: SliceState = {
  byId: {},
  byParentId: {}
};

Why This Works

Isolation: Updating byParentId[A] cannot affect byParentId[B]. There is no shared object to accidentally replace.

Per-Entity Loading States: Instead of one loading boolean, each parent tracks its own state. Chef A being busy does not mean Chef B is busy.

Deduplication: The byId map provides O(1) lookups. No duplicate entries. One source of truth for each item.

Implementation

.addCase(fetchItems.pending, (state, action) => {
  const { parentId } = action.meta.arg;
  if (!state.byParentId[parentId]) {
    state.byParentId[parentId] = { ids: [], isLoading: true };
  } else {
    state.byParentId[parentId].isLoading = true;
  }
})

.addCase(fetchItems.fulfilled, (state, action) => {
  const { parentId } = action.meta.arg;
  const ids = action.payload.map(item => item.id);

  state.byParentId[parentId] = { ids, isLoading: false };

  action.payload.forEach(item => {
    state.byId[item.id] = item;
  });
})

.addCase(fetchItems.rejected, (state, action) => {
  const { parentId } = action.meta.arg;
  if (state.byParentId[parentId]) {
    state.byParentId[parentId].isLoading = false;
    state.byParentId[parentId].error = action.error;
  }
});

Each parent gets its own container. Loading states are isolated. Responses cannot interfere with each other.


The Better Fix: Hire a Sous Chef (RTK Query)

RTK Query is like hiring a sous chef who handles all the prep work. You tell them what dishes you need. They handle caching, deduplication, and mise en place. You do not write reducer logic. You cannot introduce bugs.

Why RTK Query Prevents This

Automatic Request Deduplication: Two chefs (components) requesting the same dish share one order. No duplicate work.

Built-in Cache Management: Responses merge into a normalized cache. You do not touch state directly.

Tag-Based Invalidation: Mutations invalidate cache tags. Fresh data is fetched automatically.

Comparison

Thunk-based approach requires 50+ lines:

export const fetchItems = createAsyncThunk(
  "items/fetch",
  async ({ parentId }: { parentId: string }) => {
    return await api.getItems(parentId);
  }
);

// Plus 30-50 lines of reducer logic for pending/fulfilled/rejected
// Plus selectors
// Plus loading state management
// Plus race condition handling

RTK Query equivalent:

export const itemsApi = createApi({
  reducerPath: 'itemsApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Item'],
  endpoints: (builder) => ({
    getItems: builder.query<Item[], string>({
      query: (parentId) => `/parents/${parentId}/items`,
      providesTags: (result, error, parentId) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Item' as const, id })),
              { type: 'Item', id: `PARENT-${parentId}` }
            ]
          : [{ type: 'Item', id: `PARENT-${parentId}` }]
    }),
  })
});

No reducer logic. No state shape to design. No merge bugs. No race conditions. The sous chef handles everything.


Audit Checklist: Find the Bug in Your Codebase

Search for these patterns:

// Full state replacement
state.data = action.payload.reduce(

// Empty object accumulator
}, {} as

// Single loading flag for entity-specific fetches
state.loading = false;
state.loadingList = false;

// Array storage (should be Record<string, Item>)
data: Item[];

Questions to ask when reviewing reducers:

State Assignment

  • Does the reducer assign state.data = ... or state.data[key] = ...?
  • Does the reducer use reduce(..., {}) with an empty accumulator?
  • Does the reducer create fresh objects instead of updating existing state?

Loading State

  • Is there one loading boolean for multiple entities?
  • Does loading = false from one response affect other requests?
  • Should loading be per-entity or per-parent?

Concurrent Requests

  • Can multiple components dispatch the same thunk with different arguments?
  • What happens if responses arrive out of order?
  • Is the reducer idempotent regardless of response timing?

Data Structure

  • Are collections stored as arrays instead of byId maps?
  • Is data keyed by parent entity?
  • Are there isolation boundaries between parents?

Real-World Scenario

This bug appears when:

  1. User creates an entity with related data
  2. Initial fetch returns empty (data not yet persisted on backend)
  3. Another component fetches for a different parent
  4. The late-arriving stale response overwrites the correct data

The feature works “most of the time.” Users report intermittent data disappearing. Logs show nothing wrong. The bug is timing-dependent.

You cannot reproduce it locally because you have fast internet. Your API responds in 50ms. The race window is tiny. In production, users have slow connections. The race window is huge.


Key Takeaways

  1. Never replace entire state branches. Assign to state.data[key], not state.data.

  2. Normalize collections with byId and byParentId. This creates natural isolation.

  3. Per-entity loading states prevent one response from affecting others.

  4. RTK Query eliminates this bug class through automatic cache management.

  5. Timing bugs suggest race conditions. If it works “most of the time,” suspect concurrent state updates.

  6. Empty accumulator {} is a red flag. You are building state from scratch. Everything else is lost.

  7. Test with slow networks. Add artificial delays. Race conditions appear when responses are far apart.


Closing Thoughts

A kitchen without mise en place is chaos. Chef A trips over Chef B. Ingredients are lost. Orders are wrong. Customers complain.

Your Redux store is the same. Without isolation boundaries, reducers trip over each other. Data is lost. Users complain.

Normalize your state. Use labeled containers. Let each reducer update only its section. Or hire RTK Query and let the sous chef handle it.


Resources