Inside React's key: where it lives and how reconciliation actually uses it
Author: Sang Shaoqing
This article is based on React v19.2.8 stable
In development builds, when React renders a set of sibling children from an array or iterable, if the directly generated child nodes do not provide a key, React outputs a warning in the console:
Here is React's official explanation:
https://react.dev/learn/rendering-lists?utm_source=chatgpt.com
You need to give each item in the array a unique identifier - a string or a number - to uniquely identify that item among the other items in the array:
<li key={person.id}>...</li>The key tells React which array item each component corresponds to, so that it can match them up later. This becomes especially important if the array items can move (for example, due to sorting), be inserted, or be deleted. A well-chosen key helps React infer exactly what has happened and make the correct updates to the DOM tree.
The official explanation provides a general description of what key is used for, but it does not tell us what key actually is, or in what form it exists within React.
Since key is mainly used for diffing, what does the diff process look like?
So let's dive into React's internal key mechanism together.
Getting to know key
Setting aside everything else, let's first solve a basic problem: where does the key written in JSX end up?
For example, the following code:
We write a key on <li>, but opening the browser's Elements panel, we don't see a DOM like this:
<li key="101">Apple</li>
The actual generated DOM is still only: <li>Apple</li>
This shows that key is not an HTML attribute passed to the browser.
key is not a regular prop
Although we write key on the component tag, it is not handed to the child component like a normal attribute:
<li key={fruit.id}>{fruit.name}</li>
Inside a component, you cannot rely on props.key, because key is a reserved field in React, not a normal prop. In development, reading props.key will trigger a warning. If a component needs this id internally, you should pass it separately.
So where does React store it? There are two main places: ReactElement and Fiber.
key is first stored in ReactElement
https://github.com/react/react/blob/v19.2.8/packages/react/src/jsx/ReactJSXElement.js
JSX is just a syntax for writing React code. After compilation, JSX is usually transformed into calls to the jsx or jsxs functions.
Create a ReactElement object based on JSX:
jsx(
'li',
{ children: fruit.name },
fruit.id,
);
The core structure of ReactElement can be simplified to:
function ReactElement(
type,
key,
props,
owner,
debugStack,
debugTask,
) {
return {
$$typeof: REACT_ELEMENT_TYPE,
type,
key,
props,
// Debug info in development environment
_owner: owner,
// React also includes ref, _store, _debugInfo
//
};
}
React allows key to be a string or a number, but when creating a ReactElement it is usually normalized to a string. When no key is provided, ReactElement.key remains null, and React does not actually write the array index into the key field.
key then enters Fiber
ReactElement describes "what interface this render wants to get". React also needs a data structure that can record component state, update relationships, and work progress. This is Fiber.
https://github.com/react/react/blob/v19.2.8/packages/react-reconciler/src/ReactFiber.js
The core structure of FiberNode can be simplified to:
function FiberNode(tag, pendingProps, key, mode) {
this.tag = tag;
this . key = key;
this.elementType = null;
this . type = null ;
this.stateNode = null;
//......
}
Here we can see that Fiber also has a key field. This value is taken from ReactElement. When creating a Fiber, the source code does something like the following:
function createFiberFromElement(element, mode, lanes) {
let owner = null;
//......
const fiber = createFiberFromTypeAndProps(
element.type,
element. key ,
element.props,
//......
);
//......
return fiber;
}
Later, when React updates a list, it can use this to find the correspondence between old and new nodes.
At this point, the storage location of key is clear. When an update happens, the Fiber left from the previous render holds the old key, and the ReactElement generated in this render carries the new key. What React needs to do next, under the same parent, is to use this information to establish the correspondence between old and new nodes, and then combine with element type to determine what can be reused.
However, key is only one of the important conditions for determining node identity, and it is not "same key means reuse must happen". React will continue to judge element type and other information. How exactly it compares, we'll explain next.
Using key
Between two renders, React needs to compare the Fiber tree with the new description, and then decide what to keep, update, move, insert, or delete. This process is usually called reconciliation. The React Diff that people often talk about can be understood as part of the work that compares old and new structures.
The reconciliation process in React 19.2.8 can be understood from the following angles:
- React mainly reconciles child nodes under the same parent, and does not automatically infer node movements between arbitrary levels.
- For React Elements, React compares key first, then combines element type to decide whether the old Fiber can be reused.
- When no explicit key is given, child nodes without a key use their current position as the matching basis, so state mismatches may occur when inserting, deleting, or sorting.
Next, let's analyze the reconciliation process together with the source code.
Entry function
In React's diff, the role of key is to determine whether a node can be reused, thereby reducing unnecessary diffing and improving diff efficiency. The final diff is executed in the reconcileChildFibers function, which performs diff comparisons for both single-node and multi-node cases, separately for old and new nodes. Determining whether a node can be reused via key happens in both the single-node and multi-node diff passes.
https://github.com/react/react/blob/v19.2.8/packages/react-reconciler/src/ReactChildFiber.js:1767
Single-node comparison
Single-node Diff first needs to determine whether there is a corresponding old Fiber, and then determine whether the DOM node corresponding to the old Fiber can be reused. How does React find the corresponding old Fiber, and how does it decide whether the node can be reused? By analyzing the source code of the reconcileSingleElement function, we can outline the process as follows:
Here "single-node" means that the new child needing reconciliation is one ReactElement. The child nodes left from the previous render can still be multiple, so React needs to search along the old Fiber's sibling chain to find the corresponding node.
https://github.com/react/react/blob/v19.2.8/packages/react-reconciler/src/ReactChildFiber.js: 1634
From this, we can see that it takes the key of the new React Element and searches the old sibling Fibers:
After finding the same key, it compares type:
- If both key and type match, it creates the work-in-progress Fiber based on the old Fiber, preserving reusable nodes and state.
- If key is the same but
typeis different, the old node cannot be reused; the old structure needs to be deleted and a new structure created.
- When the current old Fiber's key does not match, it continues to look at the following sibling Fibers, while deleting old nodes that have been confirmed to be no longer needed.
Multi-node comparison
Handling a single new node primarily involves finding a continuation object among the old nodes. When the new children become a whole list, React needs to find the correspondence for each item in the list, while also handling additions, deletions, and order changes.
When multiple child nodes are updated, the following changes usually need to be handled:
- Node attributes or content are updated;
- Nodes are added or deleted;
- The relative positions between nodes change.
An update may contain only one of these changes, or it may contain several at the same time.
When new nodes are an array, React can access new nodes by index. Old child nodes are connected through the Fiber's sibling pointer into a singly linked list. Since Fiber does not have a convenient reverse pointer, React's reconcileChildrenArray mainly scans from left to right.
The whole process can be divided into three stages:
- Compare old and new nodes sequentially from left to right;
- Determine whether the new array or the old Fiber list has been fully traversed;
- If both sides still have remaining nodes, use a Map to find reusable old Fibers.
https://github.com/react/react/blob/v19.2.8/packages/react-reconciler/src/ReactChildFiber.js:1124
First stage: left-to-right sequential comparison
React traverses the new node array and the old Fiber list simultaneously:
newChildren[0] compare currentFirstChild
newChildren[1] compare currentFirstChild.sibling
newChildren[2] compare next sibling
This stage calls updateSlot, attempting to match the new node at the current position with an old Fiber.
For React Elements, updateSlot first compares keys.
- keys are different
If the keys of the old and new nodes are different, it means the current position cannot be directly matched:
function updateSlot(
returnFiber: Fiber,
oldFiber: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
const key = oldFiber !== null
? oldFiber.key
: null;
// Only the React Element branch is shown below
if (
typeof newChild === 'object' &&
newChild !== null &&
newChild.$$typeof === REACT_ELEMENT_TYPE
) {
if (newChild.key !== key) {
return null;
}
return updateElement(
returnFiber,
oldFiber,
newChild,
lanes,
);
}
// Text, Portal, Lazy, Array, Iterator branches are omitted
return null;
}
When updateSlot returns null, the current sequential scan stops.
Note here:
The stop of the sequential scan only means that the current position cannot continue to match, and does not mean that all remaining nodes have moved.
- keys are the same
If keys are the same, React continues to call updateElement to judge the element type.
function updateElement(
returnFiber: Fiber,
current: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
const elementType = element.type;
// Fragment needs special handling
if (elementType === REACT_FRAGMENT_TYPE) {
const updated = updateFragment(
returnFiber,
current,
element.props.children,
lanes,
element.key,
);
if (enableFragmentRefs) {
coerceRef(updated, element);
}
validateFragmentProps(
element,
updated,
returnFiber,
);
return updated;
}
/*
* current is not null, meaning an old Fiber exists
*
* When types match, create a new workInProgress based on the old Fiber,
* preserving component state and existing DOM
*/
if (
current !== null &&
(
// normal element type match
current.elementType === elementType ||
// Hot Reload support in dev
(__DEV__
? isCompatibleFamilyForHotReloading(
current,
element,
)
: false) ||
// Lazy components compare resolved actual type
(typeof elementType === 'object' &&
elementType !== null &&
elementType.$$typeof === REACT_LAZY_TYPE &&
resolveLazy(elementType) === current.type)
)
) {
const existing = useFiber(
current,
element.props,
);
// Update ref and parent Fiber relationship
coerceRef(existing, element);
existing.return = returnFiber;
// Reuse old Fiber
return existing;
}
/*
* No old Fiber, or element type does not match
* In this case, create a new Fiber
*/
const created = createFiberFromElement(
element,
returnFiber.mode,
lanes,
);
coerceRef(created, element);
created.return = returnFiber;
return created;
}
In reconcileChildrenArray, if keys are the same but types are different, updateElement returns a new Fiber. Since the new Fiber's alternate is null, React marks the old Fiber at the current position for deletion, then continues scanning subsequent nodes:
if (
shouldTrackSideEffects &&
oldFiber !== null &&
newFiber.alternate === null
) {
deleteChild(returnFiber, oldFiber);
}
Overall:
- key same, type same: reuse old Fiber;
- key same, type different: cannot reuse old Fiber, create new Fiber and mark old Fiber for deletion.
Therefore, different types replace the current node, but do not necessarily terminate the entire sequential scan.
Second stage: determine which side is traversed first
After the sequential scan ends, React enters different branches based on the traversal situation of the new array and the old Fiber list.
- New array has been fully traversed
If:
newIdx === newChildren.length
It means all new nodes have been processed, but the old Fiber list still has remaining nodes. These old nodes no longer exist in the new list and need to be deleted:
if (newIdx === newChildren.length) {
deleteRemainingChildren(
returnFiber,
oldFiber,
);
return resultingFirstChild;
}
- Old Fiber has been fully traversed
If:
oldFiber === null
It means none of the following new nodes have an old Fiber to reuse, so they will be created one by one:
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(
returnFiber,
newChildren[newIdx],
lanes,
);
if (newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(
newFiber,
lastPlacedIndex,
newIdx,
);
// connect to the new Fiber list
}
return resultingFirstChild;
}
These newly created Fibers will be marked by placeChild as needing insertion.
- Both old and new sides have remaining nodes
If both the new array and the old Fiber list still have unprocessed nodes, it means matching can no longer be done by current position alone. React puts the remaining old Fibers into a Map and moves to the next stage.
Third stage: use Map to find old Fiber
React first calls mapRemainingChildren to put the remaining old Fibers into a Map:
function mapRemainingChildren(
currentFirstChild: Fiber,
): Map<string | number, Fiber> {
const existingChildren = new Map<
string | number,
Fiber
>();
let existingChild = currentFirstChild;
while (existingChild !== null) {
const mapKey =
existingChild.key !== null
? existingChild.key
: existingChild.index;
existingChildren.set(
mapKey,
existingChild,
);
existingChild = existingChild.sibling;
}
return existingChildren;
}
The storage rule can be simplified as:
Has explicit key → use key as Map key No explicit key → use old Fiber's index
Next, React traverses the remaining new nodes and calls updateFromMap to find matches:
function updateFromMap(
existingChildren: Map<string | number, Fiber>,
returnFiber: Fiber,
newIdx: number,
newChild: any,
lanes: Lanes,
): Fiber | null {
if (
typeof newChild === 'object' &&
newChild !== null &&
newChild.$$typeof === REACT_ELEMENT_TYPE
) {
const mapKey =
newChild.key === null
? newIdx
: newChild.key;
const matchedFiber =
existingChildren.get(mapKey) || null;
return updateElement(
returnFiber,
matchedFiber,
newChild,
lanes,
);
}
return null;
}
The lookup rule for new nodes is:
New node has explicit key → use key to search New node has no key → use newIdx to search
After finding an old Fiber, React continues to compare types:
- key and type both match: reuse old Fiber;
- key same but type different: create new Fiber;
- no matching key found: create new Fiber.
If an old Fiber is successfully reused, React removes it from the Map:
if (
shouldTrackSideEffects &&
newFiber.alternate !== null
) {
existingChildren.delete(
newFiber.key === null
? newIdx
: newFiber.key,
);
}
After all new nodes are processed, the old Fibers remaining in the Map are those that were not reused and need to be deleted uniformly.
Supplement
After every new Fiber is obtained, React calls placeChild to determine whether the node needs to be inserted or moved, and then connects it to the new Fiber list through sibling. For insertions and moves that need to be executed, React adds a Placement flag.
To distinguish these two cases, we first need to determine: does the work-in-progress Fiber have an old Fiber that can be reused? Here we need alternate. During an update, React prepares a work-in-progress Fiber for the old Fiber that can be reused, and the two are linked through alternate. Therefore, we can find the corresponding old Fiber via newFiber.alternate. If there is no reusable old Fiber and a new Fiber is created directly, its alternate is null.
In a list update that needs to track side effects, we can distinguish two cases:
alternate === null: a node newly created in this round needs aPlacementflag to schedule insertion. This also includes cases where the old node cannot be reused and needs to be recreated.alternate !== null: an old Fiber exists and can be reused, but we still need to determine whether it should be moved.
Therefore, the judgment for new additions is relatively direct. Next, we focus on the second case:
Although a node can be reused, its relative order in the new list may have changed.
How does React determine whether it needs to change position? This part is mainly done by placeChild.
function placeChild(
newFiber: Fiber,
lastPlacedIndex: number,
newIndex: number,
): number {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
// records Forked info
newFiber.flags |= Forked;
return lastPlacedIndex;
}
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// existing node has moved
newFiber.flags |= Placement;
return lastPlacedIndex;
}
// node can keep its original relative order
return oldIndex;
}
// no old Fiber, so it's a new node
newFiber.flags |= Placement | PlacementDEV;
return lastPlacedIndex;
}
There are two important values:
oldIndex: the position of the currently reused node in the old list;
lastPlacedIndex: the largest old position among nodes that have been confirmed not to need moving.
The judgment rule can be summarized as:
- oldIndex < lastPlacedIndex
If the current node's old position falls before nodes already confirmed to be in order, it cannot maintain the original relative order and needs to move:
oldIndex < lastPlacedIndex → mark Placement
- oldIndex >= lastPlacedIndex
If the current node still maintains an increasing relative order, it does not need to move:
oldIndex >= lastPlacedIndex → no Placement mark → lastPlacedIndex = oldIndex
Understanding multi-node comparison through an example
Suppose the list changes as follows:
Old list: A B C D New list: A C D B
First process A:
A's oldIndex = 0, lastPlacedIndex = 0. 0 >= 0, so A does not need to move.
Next, C in the new list cannot match B at the current position in the old list by key, so the fast sequential scan stops. The remaining old Fibers B, C, D are put into the Map.
Find C from the Map:
C's oldIndex = 2, lastPlacedIndex = 0. 2 >= 0, so C does not need to move. lastPlacedIndex updates to 2.
Then find D:
D's oldIndex = 3, lastPlacedIndex = 2. 3 >= 2, so D does not need to move. lastPlacedIndex updates to 3.
Finally find B:
B's oldIndex = 1, lastPlacedIndex = 3. 1 < 3, so B needs to move. Mark Placement.
In the end, A, C, D do not need to move, and B is marked to be placed into its new position.
The logic of lastPlacedIndex focuses on whether the current node can maintain the increasing relative order of its old position. It does not guarantee the theoretical minimum number of moves every time.
For example:
Old list: A B C D New list: D A B C
D is matched first, its oldIndex is 3, so lastPlacedIndex updates to 3.
Then:
A: oldIndex = 0, less than 3, mark Placement B: oldIndex = 1, less than 3, mark Placement C: oldIndex = 2, less than 3, mark Placement
Intuitively, moving only D would seem simpler, but according to the current forward scan rule, React marks A, B, and C as needing moves.
Multi-node Diff Summary
Looking back, the overall process of reconcileChildrenArray can be summarized as:
Choosing key
After seeing the matching process, it becomes easier to understand the requirements for using key. React needs to find the corresponding old node via key, so a qualified key should satisfy two conditions:
- Unique among siblings: keys cannot duplicate within the same list.
- Stable across renders: the key for the same data item must not change after re-render, sort, or move.
Prefer business IDs from the backend
If the ID returned by the backend is unique in the current list, and the ID of the same record does not change with re-render, sort, or move, use it directly:
<UserItem key={user.id} user={user} />
The requirement here is uniqueness among sibling nodes, not that all keys in the entire app must be unique.
When IDs are unique only within a specific business scope, use composite keys
Some IDs are unique only within a tenant, group, or data source. When data from different scopes appears in the same list, collisions can happen. For example:
const users = [
{ tenantId: 'A', id: 1, name: 'Tom' },
{ tenantId: 'B', id: 1, name: 'Jerry' },
];
These two records have the same id, but they belong to different tenants. You can combine tenantId and id to form a complete identity:
{users.map(user => (
<UserItem
key={`${user.tenantId}:${user.id}`}
user={user}
/>
))}
The same idea applies to orders and line items, products and variants, etc.:
key={`${orderId}:${lineId}`}
key={`${productId}:${variantId}`}
Composite fields need to jointly identify a business record and remain stable as long as the record's identity does not change. If an ID that should be unique within the same business scope is still duplicated, you should first confirm whether there is duplicate data, or look for another field that can stably distinguish records.
When can index be used?
An array index represents a position. Using index as key is equivalent to letting React identify nodes by position. When a list is inserted into, deleted from, or reordered, the same position may now hold a different item, but the key corresponding to that position has not changed. When types match, React may continue to reuse the original component, leaving its input contents or internal state to another item. Therefore, for lists that undergo additions, deletions, or reordering, you should prefer a stable business identifier.
For static lists where content and order are fixed, index can be considered. The key question is whether the correspondence between a given item and its position changes during updates. When list items contain input fields, animations, or component state, the mismatch caused by position changes is often more noticeable to the user.
Summary
From JSX to ReactElement, and then to Fiber, key is always involved in node identity identification. When updating lists, React uses it to establish the correspondence between old and new nodes, and then decides the subsequent handling based on type and position. When choosing a key, the most important thing is to keep the identity of a business data item stable across multiple renders.
Top 5 from juejin.cn, machine-translated. The original thread is authoritative.
Drinking Gu Ming, learning frontend
Well written and smooth, urge more updates!
Drinking Gu Ming, learning frontend
👍👍👍
👍👍👍