Overview
Strict equality, reference identity, and immutability are core JavaScript ideas that directly affect React rendering behavior. React code often looks declarative, but under the hood React still has to answer practical questions:
- Did this state value change?
- Did this dependency change?
- Did these props change enough to re-render a memoized component?
- Is this object the same object as last render, or just a different object with the same contents?
- Did the developer mutate existing state or create a new value?
Strict equality with === compares primitive values by value and objects by reference identity. Two object literals with the same fields are still different objects:
{} === {}; // false
React relies heavily on identity-based comparisons. State updates, dependency arrays, memoization, selectors, and shallow prop comparisons all become easier to reason about when state is treated as immutable. Immutability means creating new objects or arrays when data changes instead of mutating existing ones in place.
This topic matters in interviews because many React bugs come from misunderstanding identity:
- A component does not update because state was mutated in place.
- An effect runs on every render because an object dependency is recreated every time.
React.memodoes not help because props are always new references.- A nested object mutation leaks into other parts of the UI.
- A selector returns a new array every time and causes unnecessary rendering.
A strong answer connects JavaScript equality semantics to React's rendering model and shows practical update patterns for objects, arrays, dependencies, and memoized components.
Core Concepts
Strict Equality with ===
Strict equality compares two values without type coercion.
1 === 1; // true
"1" === 1; // false
true === 1; // false
null === undefined; // false
For primitive values such as strings, numbers, booleans, null, undefined, symbols, and bigints, strict equality usually behaves like value comparison.
"react" === "react"; // true
42 === 42; // true
false === false; // true
For objects, arrays, and functions, strict equality compares reference identity. It checks whether both operands point to the exact same object in memory.
const a = { id: 1 };
const b = { id: 1 };
const c = a;
a === b; // false
a === c; // true
The same applies to arrays and functions:
[1, 2] === [1, 2]; // false
const first = () => {};
const second = () => {};
first === second; // false
Strict Equality vs Loose Equality
Loose equality with == allows type coercion.
"1" == 1; // true
false == 0; // true
null == undefined; // true
Strict equality avoids those conversions:
"1" === 1; // false
false === 0; // false
null === undefined; // false
In React and TypeScript codebases, === is usually preferred because it is predictable. Data coming from forms, URLs, APIs, and storage should be parsed or normalized explicitly instead of relying on coercion.
const pageFromUrl = Number(searchParams.get("page") ?? "1");
if (pageFromUrl === 1) {
// Clear and predictable.
}
Object.is
Object.is is another JavaScript equality algorithm. It is similar to ===, but it handles NaN, 0, and -0 differently.
NaN === NaN; // false
Object.is(NaN, NaN); // true
0 === -0; // true
Object.is(0, -0); // false
React uses Object.is semantics in important places, including comparing state values and Hook dependency values. For most everyday objects and primitives, it behaves the way developers expect: same primitive value is equal, same object reference is equal, different object reference is different.
Object.is({ id: 1 }, { id: 1 }); // false
const user = { id: 1 };
Object.is(user, user); // true
The interview-level point: React change detection is not deep equality. It is identity-oriented.
Reference Identity
Reference identity means whether two variables refer to the same object, array, or function.
const user = { id: 1, name: "Ava" };
const sameUser = user;
const copiedUser = { id: 1, name: "Ava" };
user === sameUser; // true
user === copiedUser; // false
Even though copiedUser has the same fields, it is a different object.
This matters in React because every render can create new object, array, and function values:
function UserPage({ userId }) {
const filters = { active: true }; // New object every render.
return <UserList userId={userId} filters={filters} />;
}
If UserList is memoized, this new filters object can still cause it to re-render because the prop reference changed.
Immutability
Immutability means treating values as if they cannot be changed after creation. In React state, the practical rule is: do not mutate existing state objects or arrays. Create a new value that represents the update.
Mutating state in place:
const [user, setUser] = useState({ name: "Ava", age: 30 });
function birthday() {
user.age += 1;
setUser(user); // Same object reference.
}
Immutable update:
const [user, setUser] = useState({ name: "Ava", age: 30 });
function birthday() {
setUser({
...user,
age: user.age + 1,
});
}
The immutable version creates a new object reference, so React can detect that the state changed.
Why React Cares About Identity
React needs efficient ways to decide whether work is necessary. Deeply comparing every object and array in an application would be expensive and unpredictable. Instead, React and React ecosystem tools commonly use identity-based checks.
Identity affects:
useStateupdates.useReducerreturn values.useEffectdependency arrays.useMemodependencies.useCallbackdependencies.React.memoprop comparisons.- Context provider values.
- Selectors and derived data.
Example:
function SearchResults({ query }) {
const options = { limit: 20, sort: "relevance" };
useEffect(() => {
fetchResults(query, options);
}, [query, options]);
}
options is a new object on every render, so the effect runs on every render. Better:
function SearchResults({ query }) {
useEffect(() => {
const options = { limit: 20, sort: "relevance" };
fetchResults(query, options);
}, [query]);
}
or:
function SearchResults({ query }) {
const options = useMemo(() => ({ limit: 20, sort: "relevance" }), []);
useEffect(() => {
fetchResults(query, options);
}, [query, options]);
}
The first fix is often better because it removes the object dependency entirely.
Updating Objects in State
When updating an object, copy the existing object and replace only the fields that changed.
type User = {
id: string;
name: string;
email: string;
};
const [user, setUser] = useState<User>({
id: "1",
name: "Ava",
email: "[email protected]",
});
function updateEmail(email: string) {
setUser((current) => ({
...current,
email,
}));
}
Use the functional updater form when the next state depends on the previous state:
setUser((current) => ({
...current,
loginCount: current.loginCount + 1,
}));
This avoids stale values when multiple updates are queued.
Updating Nested Objects
For nested state, copy every level on the path to the changed field.
const [profile, setProfile] = useState({
name: "Ava",
address: {
city: "Da Nang",
country: "Vietnam",
},
});
function updateCity(city: string) {
setProfile((current) => ({
...current,
address: {
...current.address,
city,
},
}));
}
Do not do this:
profile.address.city = "Hanoi";
setProfile(profile);
That mutates the existing object and passes React the same top-level reference.
If state becomes deeply nested and updates become painful, consider flattening the state shape, splitting state into smaller pieces, using a reducer, or using an immutable update helper such as Immer when appropriate.
Updating Arrays in State
Arrays should also be treated as immutable.
Add item:
setItems((items) => [...items, newItem]);
Remove item:
setItems((items) => items.filter((item) => item.id !== idToRemove));
Update item:
setItems((items) =>
items.map((item) =>
item.id === updated.id
? { ...item, name: updated.name }
: item
)
);
Avoid mutating methods on state arrays:
items.push(newItem);
setItems(items); // Same array reference.
Prefer non-mutating methods:
mapfiltersliceconcat- spread syntax
toSortedtoReversedtoSpliced
Be careful with shallow copies:
const nextItems = [...items];
nextItems[0].done = true; // Still mutates the object inside the array.
setItems(nextItems);
Better:
setItems((items) =>
items.map((item, index) =>
index === 0 ? { ...item, done: true } : item
)
);
Shallow Comparison
A shallow comparison checks only the first level of values, usually with identity comparison for object fields.
Example:
const previousProps = {
user: { id: "1", name: "Ava" },
theme: "dark",
};
const nextProps = {
user: { id: "1", name: "Ava" },
theme: "dark",
};
The theme values are equal primitives, but the user values are different object references.
React memoization tools often rely on shallow comparisons or dependency arrays. This makes structural sharing important. Structural sharing means unchanged parts of the data keep their old references while changed parts get new references.
setState((state) => ({
...state,
user: {
...state.user,
name: "Ava Nguyen",
},
settings: state.settings, // Same reference because it did not change.
}));
React.memo
React.memo can skip re-rendering a component when its props are considered unchanged. By default, React compares each prop using Object.is.
const UserCard = memo(function UserCard({ user }) {
return <h2>{user.name}</h2>;
});
This helps only if the parent passes stable props:
// New object every render, so memo is less useful.
<UserCard user={{ id: user.id, name: user.name }} />
Better:
<UserCard user={user} />
or, if the object is derived:
const cardUser = useMemo(
() => ({ id: user.id, name: user.name }),
[user.id, user.name]
);
<UserCard user={cardUser} />;
Do not add memo everywhere. It is useful when rendering is expensive, props are stable, and skipped renders are likely. It adds mental overhead and does not fix impure rendering logic.
useMemo and useCallback
useMemo memoizes a calculated value. useCallback memoizes a function reference. Both use dependency comparisons.
const visibleTodos = useMemo(
() => todos.filter((todo) => todo.visible),
[todos]
);
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
}, []);
These hooks are useful when:
- A calculation is expensive.
- A stable reference is needed for a memoized child.
- A dependency would otherwise change every render.
- A custom Hook needs to return stable functions.
They are not a replacement for clean state design. Often the better fix is to move object creation inside the effect, pass primitive props, split components, or avoid unnecessary derived state.
Effects and Dependency Identity
React compares each dependency in an effect dependency array with its previous value. Object, array, and function dependencies are compared by identity.
Problem:
function ChatRoom({ roomId }) {
const options = {
serverUrl: "https://chat.example.com",
roomId,
};
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]);
}
The options object is new every render, so the effect reconnects unnecessarily.
Better:
function ChatRoom({ roomId }) {
useEffect(() => {
const options = {
serverUrl: "https://chat.example.com",
roomId,
};
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}
This makes the dependency list reflect the actual reactive value: roomId.
Context Values and Identity
Context provider values are another common identity pitfall.
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
return (
<AuthContext.Provider value={{ user, setUser }}>
{children}
</AuthContext.Provider>
);
}
The object passed to value is new on every render, so consumers may re-render more often than needed.
Possible improvement:
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(
() => ({ user, setUser }),
[user]
);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
For larger contexts, split contexts by update frequency. For example, authentication state and theme state probably should not live in the same context value if they change independently.
Deep Equality
Deep equality checks whether nested structures have the same contents.
deepEqual({ id: 1 }, { id: 1 }); // true in a deep equality helper
React does not generally deep-compare state, props, or dependencies because deep equality can be expensive, surprising, and still imperfect for functions, class instances, dates, maps, sets, and cyclic data.
Use deep equality carefully:
- It may be fine in tests.
- It may be acceptable for small, bounded data.
- It is risky in hot rendering paths.
- It can hide better state design.
In React, prefer stable references, primitive dependencies, normalized data, and memoized selectors over deep comparisons as a default strategy.
Common Mistakes
Common mistakes include:
- Using
==and relying on type coercion. - Expecting two object literals with the same fields to be equal.
- Mutating state and passing the same reference to a setter.
- Copying an array but mutating objects inside it.
- Creating new objects or functions in render and passing them to memoized children.
- Putting unstable objects in effect dependency arrays.
- Using
React.memowithout stabilizing props. - Using deep equality as a default fix for poor state shape.
- Assuming
constmakes an object immutable.
Best Practices
Use these rules of thumb:
- Prefer
===for comparisons unless there is a specific reason to use another algorithm. - Remember that objects, arrays, and functions compare by reference.
- Treat React state as immutable.
- Use functional state updates when the next value depends on the previous value.
- Copy every changed level of nested state.
- Keep state as flat as practical.
- Avoid unnecessary object and function dependencies in effects.
- Use
useMemo,useCallback, andReact.memowhere they solve a measured or obvious identity problem. - Prefer clear data flow over clever equality workarounds.