React Reconciliation: The Case of the Bleeding Content
Introduction
Every once in a while, a seemingly simple UI glitch turns into a deep exploration of how React really works under the hood. This is the story of a content "bleed" issue we encountered - where navigating between pages caused old content to persist - and what we learned about React's reconciliation process (and why keys really matter).
The Bug
We noticed that when navigating between two CMS-driven pages, such as /solutions/b2b-loyalty to /pricing, some content from the first page would remain visible until a full reload.
Debug logs showed that both pages contained non-unique section IDs:
solutions/b2b-loyalty → [10, 72, 36, 11, 2, 10, 5, 27, 715]
pricing → [1, 71, 1, 10, 161, 8, 10, 18, 1]
React saw <Section key="10" /> on both routes and assumed they represented the same component. The result: React reused the previous DOM subtree and the old content bled through.
Understanding the Root Cause
React's rendering engine (React Fiber) performs reconciliation - a process where it compares the previous render tree to the new one to determine the minimal DOM changes.
- If a component's type and key match between renders, React reuses the existing component instance
- If either changes, React unmounts the old one and mounts a new one
Because the CMS reused numeric IDs across pages, React couldn't tell that a <Section key="10" /> on /pricing was not the same logical section as <Section key="10" /> on /solutions. Reconciliation did its job - it just had the wrong hint.
The Wrong Fixes (and Why They Worked Temporarily)
1. Global Remounts
<Component {...pageProps} key={router.asPath} />
This forces React to remount the entire app whenever the URL changes. It "fixes" the bleed because it blows away every component - but at a cost:
- All local state is lost
- Third-party SDKs re-initialize
- Every effect runs again
- Performance tanks
2. Using key={index}
This also seemed to work, because indexes shifted between pages - but it's an anti-pattern. If the list order ever changes, React will reuse the wrong items, causing even worse state mismatches.
The Real Fix
We needed keys that actually represent unique identity. Since our CMS data provided a __component field and an id, we combined them:
{filteredContent.map((section) => (
<div key={`${section.__component}:${section.id}`}>
<Section sectionData={section} integrations={integrations} />
</div>
))}
Now React sees:
"products.call-to-action-v2:10"
"global.testimonial:10"
and treats them as distinct elements even if the IDs overlap. This removed the stale content issue immediately - without a global remount.
Why This Works
React keys are part of its Fiber reconciliation identity. During diffing:
- If type and key match - React reuses the existing fiber
- If either differs - React tears down the old fiber and builds a new one
By giving each section a globally unique key, we correctly tell React what's truly the same and what's not.
Lessons Learned
- Keys are not cosmetic - They're fundamental to React's diffing logic
- Index as key is an anti-pattern - Only use it for static, never-changing lists
- Composite keys are fine - When backend IDs aren't globally unique
- Avoid top-level remounts - Like
key={asPath}in_app.js- it's a sledgehammer that hides underlying reconciliation issues - Understanding React Fiber helps - Debug "invisible" issues like this - it's not about the DOM, it's about the virtual representation
A Quick Note on React Fiber
React 16 introduced Fiber, a new reconciliation engine that replaced the old stack-based algorithm. Fiber breaks rendering into small "units of work" so React can pause, resume, or abandon renders.
During reconciliation, Fiber compares each node's type and key:
- Match - Reuse the existing node and update props
- No match - Unmount and create a new one
That's why unique, stable keys are essential. They're how React decides whether to patch or rebuild a component.
Bonus: Strapi v5 Makes This Easier
In our case, the root cause was that our CMS reused section IDs. One of the reasons we like Strapi v5 is that it now generates globally unique IDs for every component and section. That means you can safely use:
key={section.id}
without fear of collisions - and React's reconciliation logic will always behave predictably.
Final Thoughts
This bug was a great reminder that small implementation details - like how you choose a key - can have big architectural implications. Understanding how React's reconciliation actually works makes you better equipped to debug issues that don't appear in the console but manifest in the UI.
React didn't do anything wrong - it did exactly what we told it to do. We just gave it the wrong identity hint.
If you've ever chased a ghost bug caused by React reusing the wrong component, take a minute to inspect your keys. They're more important than you think.