How to Fix React Unstable Key Prop Remounting: Stop Silent State Loss and Component Thrashing
Bug Severity: CRITICAL | UX/User Impact: SEVERE | Time to Fix: 15 mins
TL;DR
- What broke: A
keyprop resolves to a new value on every render — React interprets this as a different component instance, unmounts the old one, and mounts a fresh one, nuking all local state. - How to fix it: Replace the unstable key expression with a stable, data-derived identifier (
item.id,item.uuid, a slug). Never useMath.random(),Date.now(), inline objects, or bare array index on lists that reorder. - Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your component and get a stable-key rewrite without sending your code to a third-party server.
The Incident (What Does the Error Mean?)
There is no thrown exception. That is what makes this brutal to catch in production. The symptom surface looks like:
// Console — React DevTools Profiler
Component <SearchFilter> unmounted
Component <SearchFilter> mounted ← happens every render cycle
// User-visible symptoms
- Text input resets to empty mid-typing
- Dropdown selection clears on parent re-render
- useEffect fires on every keystroke (fetch storm)
- Animations restart from frame 0
- Controlled form loses field values silently
React's reconciler uses key as the primary identity signal. When key changes between renders, React does not diff the component — it destroys the fiber, unmounts the tree, and constructs a new one from scratch. This is by design and is not a bug in React. The bug is yours.
Common offenders found in production codebases:
// Offender 1: Math.random() as key
items.map(item => <Row key={Math.random()} {...item} />)
// Offender 2: Date.now() as key
<Modal key={Date.now()} isOpen={open} />
// Offender 3: Inline object reference (always new reference)
<Chart key={{id: chart.id}} data={chart.data} />
// Offender 4: Array index on a list that filters/sorts
items.map((item, index) => <Card key={index} {...item} />)
The Attack Vector / Blast Radius
This is not a cosmetic bug. Trace the full failure cascade:
1. State destruction loop.
Every parent re-render (triggered by a Redux dispatch, a context update, a window resize handler, anything) generates a new key → child unmounts → useEffect cleanup runs → child mounts → useEffect setup runs again. If that effect fires a fetch(), you now have an unbounded request storm tied to render frequency.
2. Form data loss at scale. In a multi-step checkout or onboarding flow, an unstable key on a form section means users lose filled data when an adjacent sibling updates. Support tickets spike. Conversion drops. The engineering team spends two sprints chasing a "flaky form" that reproduces inconsistently because it depends on render timing.
3. Memory and CPU thrash.
React must allocate new fiber nodes, run all lifecycle hooks, re-attach DOM nodes, and re-execute all child useEffect calls on every cycle. In a list of 200 rows with an unstable key, this is 200 full unmount/mount cycles per render. On low-end Android devices this is a jank cliff.
4. Third-party SDK re-initialization. If the remounting component initializes a Stripe Element, a Google Maps instance, a WebSocket connection, or a video player — each remount tears down and re-creates that SDK instance. Stripe re-initialization mid-payment flow is a direct revenue impact.
How to Fix It (The Solution)
Basic Fix — Use a Stable Data Identity
The key must be stable across renders and unique among siblings. If your data has an id field, use it. Full stop.
- items.map((item, index) => (
- <ProductCard key={Math.random()} product={item} />
- ))
+ items.map((item) => (
+ <ProductCard key={item.id} product={item} />
+ ))
- <UserProfileModal key={Date.now()} userId={activeUser.id} />
+ <UserProfileModal key={activeUser.id} userId={activeUser.id} />
Enterprise Best Practice — Enforce Stable Keys at the Data Layer
The root cause is often that the data arriving from the API has no stable identifier. Fix this at the source, not at the render layer.
Step 1: Guarantee IDs from your API contract.
// API response normalization (e.g., RTK Query / Zustand slice)
- const items = response.data.items // [{name: 'foo'}, {name: 'bar'}]
+ const items = response.data.items.map((item, i) => ({
+ ...item,
+ // Deterministic ID from content hash or server-assigned UUID
+ id: item.id ?? item.slug ?? `item-${item.name}-${i}`,
+ }))
Step 2: Co-locate key logic with the data shape, not the render.
// types/product.ts
- export type Product = { name: string; price: number }
+ export type Product = { id: string; name: string; price: number }
+ // id is non-optional. If your backend omits it, fix the backend.
Step 3: When array index IS acceptable — only when the list is static (never reorders, never filters, never inserts mid-list) and items have no persistent state. Even then, prefer a content-derived key.
// Static, read-only navigation tabs — index is tolerable but not preferred
- TABS.map((tab, i) => <Tab key={i} label={tab.label} />)
+ TABS.map((tab) => <Tab key={tab.id} label={tab.label} />)
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your component trees, user data shapes, and internal API structures. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing component into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
This class of bug is fully preventable with static analysis. Wire these into your PR pipeline so it never reaches review.
1. ESLint — react/no-array-index-key (immediate win)
// .eslintrc.json
{
"rules": {
"react/no-array-index-key": "error"
}
}
This rule flags key={index} on mapped elements. It will not catch Math.random() — you need the next rule for that.
2. Custom ESLint rule or eslint-plugin-react-hooks audit
For Math.random() and Date.now() in key position, write a targeted no-restricted-syntax rule:
{
"rules": {
"no-restricted-syntax": [
"error",
{
"selector": "JSXAttribute[name.name='key'] CallExpression[callee.object.name='Math'][callee.property.name='random']",
"message": "Math.random() as a key prop causes remount on every render. Use a stable data identifier."
},
{
"selector": "JSXAttribute[name.name='key'] CallExpression[callee.object.name='Date'][callee.property.name='now']",
"message": "Date.now() as a key prop causes remount on every render. Use a stable data identifier."
}
]
}
}
3. React DevTools Profiler in CI (Playwright / Cypress)
Instrument your E2E suite to assert that high-value components do not unmount during user interactions:
// cypress/e2e/checkout-form.cy.js
it('form fields survive parent re-render', () => {
cy.get('[data-testid="email-input"]').type('[email protected]')
// Trigger a parent re-render (e.g., promo code apply)
cy.get('[data-testid="apply-promo"]').click()
// Field must retain value — if key was unstable, this fails
cy.get('[data-testid="email-input"]').should('have.value', '[email protected]')
})
4. Bundle-level enforcement with Danger.js
Flag any PR diff that introduces a key={} expression containing random, now, or an inline object literal:
// dangerfile.js
const keyAntiPatterns = /key=\{[^}]*(Math\.random|Date\.now|\{[^}]+\})/g
const offendingFiles = danger.git.modified_files.filter(f => f.endsWith('.tsx') || f.endsWith('.jsx'))
offendingFiles.forEach(async (file) => {
const content = await danger.github.utils.fileContents(file)
if (keyAntiPatterns.test(content)) {
fail(`Unstable key prop pattern detected in ${file}. Use a stable data identifier.`)
}
})