How React keys identify components
Why do React keys need to identify components?
In the React Fiber reconciliation process, a key is not just an optimization — it acts as a precise identifier for a component. When keys are duplicated, you'll see a warning in development mode, but the more serious problem is that React ends up reusing components incorrectly, which causes the mount/unmount lifecycle to behave differently from what you intended.
I actually ran into a component duplication issue caused by React keys not being properly distinguished. Through that experience, I came to understand the importance of React keys and how the mount/unmount lifecycle really works.
The problem
In a collaborative editing scenario, an issue came up where cursors were being duplicated whenever three or more people were editing at the same time.
A collaborative editing environment combined with an unprecedented component duplication phenomenon meant I couldn't even guess what was causing the problem. In particular, reproducing a 3+ person collaborative editing situation by myself was tough. By using two computers to open three web pages and running multiple test rounds, I was finally able to pin down the exact issue scenario.
Reproduction scenario:
- On the screen that User A is observing
- User B and User C are editing the same piece of text
- User B moves the cursor to a different text component
- Result: On User A's screen, User B's cursor shows up in both the previous and the new position at the same time
Identifying the cause
At first I suspected network sync delays in the collaborative editing environment or some issue with state update ordering. After repeated debugging though, I found the cause was surprisingly simple.
It was a React key duplication.
On User A's screen, the cursor components of all the remote users were using remoteTextId as their key, and because of this, React couldn't distinguish between the cursors of multiple users editing the same text.
When keys are duplicated during React's reconciliation process, React can't correctly decide which component to keep and which one to remove. So when User B moved to a different text, the cursor at the previous position wasn't unmounted and remained there, while another cursor was added at the new position, resulting in the component duplication phenomenon where a single user's cursor was displayed in two places at once.
Example of a bad key
{remote.map(p => (
<RemoteCursor
key={p.remoteTextId} // uses only the text ID — can collide within sibling scope
/>
))}React reconciliation and the mount/unmount lifecycle
The React Reconciliation process
When a new render happens, React compares the previous Virtual DOM with the new Virtual DOM and decides what changes to apply to the actual DOM. This process is called reconciliation.
Reconciliation tries to perform only the DOM manipulations that are strictly necessary, for the sake of performance. For example, if only 1 item out of a list of 1000 has changed, React updates only that one item instead of redrawing the whole list.
React component lifecycle summary
| Stage | When | Key actions |
|---|---|---|
| Mount | When a component is first created and added to the DOM | Create the instance, add DOM elements, set initial state, register event listeners |
| Update | When props or state change | Compare old and new values, re-render only the changed parts, reflect changes in the DOM |
| Unmount | When the component is removed from the DOM | Run cleanup, release event listeners/timers/subscriptions, remove DOM elements and clean up memory |
Key points
- Mount: The "birth" of a component — initialize the necessary resources
- Update: The "change" of a component — react appropriately to state changes
- Unmount: The "death" of a component — clean up to prevent memory leaks
The component duplication issue in terms of React reconciliation and the lifecycle
Reconciliation perspective
React identifies components by key when comparing the Virtual DOM.
All of the remote users' cursor components were using the same key, and they were editing the same text. As a result, components with the same key were duplicated within a single component tree.
-
React interprets keys as "same key → same component".
-
Because C was continuously editing, the cursor component sharing the same
keywas alive, and that component was continuously being updated. -
For this reason, when B moved, B's cursor should normally have been unmounted, but C's update was reflected first, and B's unmount never took effect.
-
In the end, React didn't unmount B's cursor and instead treated it as an update of the component with the same key.
-
At the same time, another B cursor was newly mounted on the new text, so the result was that B's cursor existed in two places simultaneously.
Bug scenario
text1: B moves → (B unmount pending) → C edits → C updates & B unmount skipped & B updated
text2: B mounts
Correct behavior
text1: B moves → B unmounts → C edits → C updates
text2: B mounts
As a result, B's cursor ended up existing on both texts simultaneously — a duplication phenomenon.
Lifecycle perspective
Normally, when the cursor moves, the flow should look like this.
-
Previous block:
RemoteCursor(B)→ unmount (useEffect cleanup) -
New block:
RemoteCursor(B)→ mount (useEffect)
Example of a normal log
[UNMOUNT] B, alpha
[MOUNT] B, beta
But in the actual bug, it played out like this.
-
Previous block:
RemoteCursor(B)andRemoteCursor(C)share the same key -
C is still editing → React judges this key as "an already existing component"
-
Instead of unmounting B, it's treated as an update
-
Another B is mounted on the new block
Example of a buggy log
[MOUNT] B, beta
[UNMOUNT] B, alpha (none)
In short, the problem was that as B moved, the update replaced the call to unmount before it could happen, so the unmount was skipped.
-
A
keyis not just a value for optimization — it's a component identifier. -
Especially in cases like collaborative editing where multiple users can edit the same block simultaneously, you absolutely must use a unique value such as sessionId as the key.
-
Otherwise, React can end up mistakenly treating it as an update during reconciliation → skipping the unmount → causing a duplication kind of bug.
OT, sessionId, and the fix
Sessions in OT-based collaboration
Collaborative editing is typically implemented based on the OT (Operational Transformation) algorithm.
-
It transforms users' edit operations so they can be accepted regardless of order or timing.
-
To distinguish between users at this layer, each user is given a unique value called a
sessionId.
In other words, at the OT level, "who performed which operation" is identified via the sessionId.
sessionId is needed in the UI too
The cursor display is also UI that shows "who is editing where".
But in the existing code, the key was using only remoteTextId without the sessionId.
As a result, whenever B and C were in the same block, a key collision occurred, and React couldn't distinguish the cursors properly.
Solved with sessionId
The fix was simple. Incorporate the sessionId — which already exists in OT — into the key design.
Using sessionId always guarantees uniqueness within sibling scope.
// Fixed code
{remote.map(p => (
<RemoteCursor
key={p.sessionId}
/>
))}So why does React identify by key...
This issue started off as a simple-looking phenomenon in the UI — cursors that "appeared to be duplicated" — but the root cause lay in the React key design.
At first I suspected network sync issues or state update ordering, but the cause turned out to be surprisingly basic. I'd missed the simple principle of "same key → wrong reconciliation → missing unmount".
A key is not just a means of performance optimization but a mechanism that determines a component's identity. Just because you don't see any warnings doesn't mean you're safe — key collisions can tear down a component's identity in unpredictable ways.