React often needs to know whether a value changed. That question appears in state updates, Hook dependency arrays, and memoized props.
JavaScript already has Object.is, but React also ships a tiny fallback for environments that do not provide it. Reading that function is a good way to understand why references matter so much in React.
JavaScript equality is not one thing
Loose equality converts values before comparing them:
0 == false; // true
"42" == 42; // trueStrict equality avoids that conversion, but it has two results worth knowing:
NaN === NaN; // false
+0 === -0; // trueObject.is uses the same-value algorithm. For ordinary values it behaves much like ===, while handling those two cases differently:
Object.is(NaN, NaN); // true
Object.is(+0, -0); // false
Object.is(42, 42); // trueObjects and functions still compare by identity:
Object.is({}, {}); // false
const project = { name: "FortyOne" };
Object.is(project, project); // trueTwo object literals can contain the same data and still be different values because they are different objects.
React's helper
The current React source uses native Object.is when it exists and falls back to this function:
function is(x, y) {
return (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y);
}
const objectIs = typeof Object.is === "function" ? Object.is : is;The compressed expression looks strange, but it handles two small edge cases.
Signed zero
When x === y, the function would normally return true. Zero gets one extra check because JavaScript keeps its sign during division:
1 / +0; // Infinity
1 / -0; // -InfinityThat makes 1 / x === 1 / y false when one value is positive zero and the other is negative zero.
NaN
NaN is the only JavaScript value that is not equal to itself:
NaN !== NaN; // true
42 !== 42; // falseSo if both x !== x and y !== y are true, both values are NaN and the helper treats them as the same value.
Where this shows up in React
React compares Hook dependencies with Object.is. A primitive dependency that stays the same does not cause an Effect to run again:
useEffect(() => {
connectToProject(projectId);
}, [projectId]);An object created during rendering is different every time:
const options = { projectId };
useEffect(() => {
connect(options);
}, [options]);Even when projectId is unchanged, options has a new identity. In this case I would create the object inside the Effect and depend on projectId directly.
React.memo also compares each prop with Object.is by default. This is why an inline object or function can make a memoized child render again. It does not mean every value needs useMemo or useCallback; the extra memoization may cost more than the render. Measure first.
Shallow comparison and mutation
React's shallowEqual helper starts with the same-value check. If the two inputs are different objects, it compares their keys and then uses Object.is for each top-level value.
It does not walk nested objects. That is why mutation can hide a change:
const previous = {
profile: { name: "Jose" },
};
const next = previous;
next.profile.name = "Joseph";
Object.is(previous, next); // trueThe data changed, but the reference React can compare did not.
An immutable update creates new references along the changed path:
const next = {
...previous,
profile: {
...previous.profile,
name: "Joseph",
},
};
Object.is(previous, next); // falseUnchanged branches can keep their references. Changed branches receive new ones. This gives React a cheap signal without requiring a deep comparison of the whole object.
The practical model is simple: primitives compare by value, objects compare by identity, and React uses those comparisons as one input to its rendering decisions. Once I keep reference identity in mind, many surprising Effect runs and missed updates stop being surprising.