Skip to content

React Duplicate Keys from localStorage: My Two-Layer Fix

Adityo Guni Waluyo

Legacy localStorage ids collided in my React chat. Here is the reindex plus seeding fix that silenced the duplicate key error.

TL;DR

A React "duplicate key" warning came from legacy localStorage chat data holding duplicate turn ids, plus a counter effect that read stale values and minted collisions. The fix: reindex every restored turn to 0..n-1 and seed the next id inside the restore effect itself. Lesson: treat persisted data as untrusted and normalize it at the boundary.

There it was, in red: Encountered two children with the same key. The page was not even broken, which made it worse. I had simply reopened the /ai chat page in my-apps, the app restored the conversation history from localStorage, and React protested. My first guesses went everywhere, from broken data structures to a bug in React itself. The truth was less dramatic and more instructive: the legacy stored data contained duplicate turn ids, and the way I handed out new ids made collisions easier, not harder.

Two layers of trouble: duplicate ids and a fragile counter

The first layer was the data. Old chat history in localStorage held turns whose ids were not unique, say two turns both numbered 2. React's rule here is blunt: a key is how React matches an array item to the element it rendered before, and keys must be unique among siblings. The official docs put it plainly: keys tell React which array item each component corresponds to so it can match them up later, and they must be unique among siblings [1]. Component state is also tied to a position in the render tree [6], so keys that collide mean identities that collide.

The second layer was my own code. I numbered new turns with an old habit: a nextIdRef updated in a separate effect. That worked fine until a mass restore happened. An effect reading a ref at the wrong moment can act on a stale value, and that is exactly where duplicates were born. The docs have warned about this all along: effects are an escape hatch for synchronizing with systems outside React, and by the time an effect runs, it does not know what the user just did [2]. Numbering turns is internal bookkeeping, and I was forcing it through the wrong channel.

One honest footnote about index keys: the old docs never said "never". They allowed the item index as a last resort when items have no stable ids and the order never changes, while warning that changing order hurts performance and can scramble component state [5]. A chat feed fails both conditions, so index keys were never a real option here.

The two-layer fix in commit c30eb3a

Commit c30eb3a touched two files, AiChatWorkspace.tsx and ai-chat.ts [4]. The first fix lives in the history loader: every turn restored from localStorage is reindexed into a clean 0 to n-1 sequence. Legacy data with duplicate ids gets repaired at the door, so a key collision from the restore path becomes impossible.

// ai-chat.ts — reindex on restore
const stored = raw
  .filter(isStoredTurn)
  .slice(-HISTORY_MAX_TURNS)
  .map((t, i) => ({
    ...t,
    id: i, // legacy data can hold duplicate ids -> always unique here
    enhanced: typeof t.enhanced === "string" ? t.enhanced : "",
    sources: Array.isArray(t.sources) ? t.sources : [],
    followups: Array.isArray(t.followups) ? t.followups : [],
  }));

The second fix is the one I like more: I deleted the separate effect that maintained the counter. Splitting restore and numbering across two effects is what made the timing fragile in the first place. Now the next free id is seeded inside the restore effect itself:

// AiChatWorkspace.tsx — seeding inside the restore effect
useEffect(() => {
  const stored = loadHistory();
  if (stored.length > 0) setTurns(stored);
  // new ids must never collide with restored ones
  nextIdRef.current = stored.reduce((m, t) => Math.max(m, t.id + 1), 0);
  setHydrated(true);
}, []);

One effect, one source of truth. The value is always computed from the data that was just restored, never from a leftover render. That matches how React state actually works: a set call does not change the current render, it only affects the next one, and queued updaters apply in order during that render [3]. Any logic that depends on timing assumptions across renders is where bugs breed.

Why this fix makes sense

The part most write-ups skip: loading from localStorage genuinely is synchronization with a system outside React, so an effect is the right home for it. What was wrong was parking the internal numbering logic in a second, separate effect. Transform data at the point where it enters, and stop outsourcing it to a side observer whose timing you cannot guarantee.

The broader lesson: treat persisted data as untrusted. It can hold duplicate ids, gaps, or a schema written by an older version of the app. Normalize on the way in, keep one source of truth for id generation, and let React handle the rest. I hit a cousin of this problem before, when typing into a controlled textarea programmatically, which I wrote up in taming the React textarea. Small clarity about how React sees the world keeps dissolving bugs that look supernatural.

Sources

Related articles