The Temptation
You’re building a dashboard and need a profile card for the logged-in user.
It’s quick, it’s simple, so you write:
function Dashboard({ user }) {
function ProfileCard() {
return (
<div className="card">
<h2>{user.name}</h2>
<p>{user.role}</p>
</div>
);
}
return (
<div>
<h1>Dashboard</h1>
<ProfileCard />
</div>
);
}
It works. The UI renders. You move on. 🚀
But lurking under the surface is a performance and state bug just waiting to bite.
The Hidden Problem
Every time Dashboard
re-renders, whether because user
changes or some unrelated state updates, React recreates the ProfileCard
function.
That one fact breaks React’s reconciliation process and can lead to:
- State loss inside ProfileCard
- Extra unmount/mount cycles
- Wasted DOM operations
Quick Refresher: What is Reconciliation?
Reconciliation is how React updates the UI without throwing everything away.
Steps simplified:
- Build a new virtual DOM tree from your latest render.
- Compare it to the previous virtual DOM tree.
- Update only the parts that changed in the real DOM.
For React to do this efficiently, it relies heavily on component identity.
How React Tracks Component Identity
In the virtual DOM, each element has a type
property:
- Strings for DOM nodes (
'div'
,'span'
, etc.) - Functions or classes for React components
Example:
{
type: ProfileCard, // function reference
key: null,
props: { name: "Jane" }
}
During reconciliation, React compares the type of the old element and the new element.
The type
is the function or class reference for a component, or a string for a DOM element (like 'div'
or 'span'
).
If the type changes, React assumes:
“This is a completely different component.”
When that happens, React will:
- Unmount the old component (run cleanup effects, discard state).
- Mount the new one from scratch (run the function body fresh, initialize hooks).
The Problem with Inline Components
When you define a component inside another component:
function Dashboard() {
function ProfileCard() { /* ... */ }
return <ProfileCard />;
}
React creates a brand-new component on every render.
- On render #1,
type
=ProfileCard@0x001
(memory address A) - On render #2,
type
=ProfileCard@0x002
(memory address B)
They may look the same in code, but they are different function objects in memory.
React’s check:
oldFiber.type !== newFiber.type // true
And that triggers a full unmount + mount.
Result:
- Any local state in
ProfileCard
is lost. - Effects run cleanup + re-run from scratch.
- DOM nodes are destroyed and recreated.
Real-World Impact
Let’s say ProfileCard
has a form for editing user info:
function ProfileCard() {
const [editing, setEditing] = useState(false);
// form logic...
}
If Dashboard
re-renders due to a theme change or notification count update:
- Old
ProfileCard
is unmounted. - New
ProfileCard
is mounted. -
editing
resets tofalse
. - Your user loses unsaved changes. 😬
The Correct Approach
Move ProfileCard
out so its identity is stable:
function ProfileCard({ name, role }) {
return (
<div className="card">
<h2>{name}</h2>
<p>{role}</p>
</div>
);
}
function Dashboard({ user }) {
return (
<div>
<h1>Dashboard</h1>
<ProfileCard name={user.name} role={user.role} />
</div>
);
}
Now:
-
ProfileCard
keeps the same reference across renders. - React preserves state and DOM.
Takeaway
React’s reconciliation is built on stable component identity.
Defining components inside other components breaks that stability, causing unnecessary unmounts and state loss.
Top comments (0)