DEV_NET_CORE
GET_STARTED
ReactJavaScript fundamentals

Strict equality, reference identity, and immutability implications

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:

Code
{} === {}; // 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.memo does 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.

Code
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.

Code
"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.

Code
const a = { id: 1 };
const b = { id: 1 };
const c = a;

a === b; // false
a === c; // true

The same applies to arrays and functions:

Code
[1, 2] === [1, 2]; // false

const first = () => {};
const second = () => {};

first === second; // false

Strict Equality vs Loose Equality

Loose equality with == allows type coercion.

Code
"1" == 1; // true
false == 0; // true
null == undefined; // true

Strict equality avoids those conversions:

Code
"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.

Code
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.

Code
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.

Code
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.

Code
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:

Code
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:

Code
const [user, setUser] = useState({ name: "Ava", age: 30 });

function birthday() {
  user.age += 1;
  setUser(user); // Same object reference.
}

Immutable update:

Code
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:

  • useState updates.
  • useReducer return values.
  • useEffect dependency arrays.
  • useMemo dependencies.
  • useCallback dependencies.
  • React.memo prop comparisons.
  • Context provider values.
  • Selectors and derived data.

Example:

Code
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:

Code
function SearchResults({ query }) {
  useEffect(() => {
    const options = { limit: 20, sort: "relevance" };
    fetchResults(query, options);
  }, [query]);
}

or:

Code
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.

Code
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:

Code
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.

Code
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:

Code
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:

Code
setItems((items) => [...items, newItem]);

Remove item:

Code
setItems((items) => items.filter((item) => item.id !== idToRemove));

Update item:

Code
setItems((items) =>
  items.map((item) =>
    item.id === updated.id
      ? { ...item, name: updated.name }
      : item
  )
);

Avoid mutating methods on state arrays:

Code
items.push(newItem);
setItems(items); // Same array reference.

Prefer non-mutating methods:

  • map
  • filter
  • slice
  • concat
  • spread syntax
  • toSorted
  • toReversed
  • toSpliced

Be careful with shallow copies:

Code
const nextItems = [...items];
nextItems[0].done = true; // Still mutates the object inside the array.
setItems(nextItems);

Better:

Code
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:

Code
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.

Code
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.

Code
const UserCard = memo(function UserCard({ user }) {
  return <h2>{user.name}</h2>;
});

This helps only if the parent passes stable props:

Code
// New object every render, so memo is less useful.
<UserCard user={{ id: user.id, name: user.name }} />

Better:

Code
<UserCard user={user} />

or, if the object is derived:

Code
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.

Code
const visibleTodos = useMemo(
  () => todos.filter((todo) => todo.visible),
  [todos]
);
Code
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:

Code
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:

Code
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.

Code
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:

Code
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.

Code
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.memo without stabilizing props.
  • Using deep equality as a default fix for poor state shape.
  • Assuming const makes 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, and React.memo where they solve a measured or obvious identity problem.
  • Prefer clear data flow over clever equality workarounds.

Interview Practice

PreviousPromises and asynchronous JavaScriptNext UpNarrowing and control-flow analysis