JavaScript: value, reference, scope
Skip this page if you already know…
- that
{} === {}isfalse, and why; - that
constfreezes the binding, not the contents; - what a closure captures;
- why an inline object in a prop makes
useEffectrun again.
The problem
This useEffect runs in an infinite loop, and the code looks spotless:
function List({ status }) {
const filter = { status, order: "desc" };
useEffect(() => {
fetchItems(filter);
}, [filter]); // ← runs on every render, always
return null;
}
The dependency array compares with Object.is, which for objects is reference
comparison. filter is a new object on every render, so it is never "the same", and
the effect fires — which causes a render — which creates another object — which
fires the effect.
Without understanding value vs. reference, useMemo, useCallback and
React.memo are superstition: you copy the pattern without knowing what it
prevents. That is what this page is about.
Value and reference
JavaScript has two groups of types:
- Primitives —
number,string,boolean,null,undefined,symbol,bigint. Copied by value. - Objects —
{},[],function,Date,Map. Copied by reference (the variable holds an address, not the contents).
Run this in the browser console:
const a = 1;
const b = a;
console.log(a === b); // true — same value
const x = { n: 1 };
const y = { n: 1 };
console.log(x === y); // false — same contents, different addresses
const z = x;
console.log(x === z); // true — same address
z.n = 2;
console.log(x.n); // 2 — z and x are the SAME object
The third line is the one that breaks useEffect: two objects with identical
contents are not equal to ===.
const is not immutability
const config = { debug: false };
config.debug = true; // ✅ allowed — the contents changed
config = {}; // ❌ TypeError — the binding cannot be reassigned
const forbids pointing at a different object. It says nothing about the
object. To freeze the contents there is Object.freeze, and it is shallow.
Shallow and deep copies
Spread copies one level:
const original = { name: "Ana", address: { city: "Recife" } };
const copy = { ...original };
copy.name = "Bia";
console.log(original.name); // "Ana" — top level copied ✅
copy.address.city = "Olinda";
console.log(original.address.city); // "Olinda" — the nested object is the SAME ⚠️
For a deep copy of serialisable data, modern browsers have
structuredClone(original).
Scope and closures
A function "remembers" the variables of the place where it was written, not where it was called from. That is a closure:
function createCounter() {
let n = 0;
return {
increment: () => ++n,
read: () => n,
};
}
const c = createCounter();
c.increment();
c.increment();
console.log(c.read()); // 2 — `n` outlived createCounter()
In React this becomes the stale closure: a setTimeout or a listener created
during one render captures that render's values, and unless you recreate it, it
keeps looking at the past.
useEffect(() => {
const id = setInterval(() => {
console.log(counter); // always the value from the render the effect ran in
}, 1000);
return () => clearInterval(id);
}, []); // empty array: the effect is never recreated
Every cleanup function exists because of this
return () => clearInterval(id) is not good manners: without it, every render
that recreates the effect leaves a live interval capturing a different past.
That is a leak and a logic bug at the same time.
Equality: ===, Object.is and deep equality
| Comparison | What it does |
|---|---|
== |
Coerces types before comparing. Do not use it. |
=== |
Compares value (primitives) or reference (objects). |
Object.is |
Like ===, but NaN equals NaN and +0 differs from -0. React uses this. |
| Deep equality | Walks the structure comparing leaf by leaf. Not built in — it is code. |
Where it shows up in the SDK
The fix for the opening example is pinning the reference:
import { useMemo, useEffect } from "react";
function List({ status }: { status: string }) {
const filter = useMemo(() => ({ status, order: "desc" }), [status]);
useEffect(() => {
fetchItems(filter);
}, [filter]); // ✅ only when `status` actually changes
}
But the reference is not always yours to memoise — when the object comes from
outside, through a prop or an HTTP response, there is nowhere to put the
useMemo. For that case the SDK exports useDeepMemo:
import { useEffect } from "react";
import { useDeepMemo } from "tempest-react-sdk";
function Report({ filters }: { filters: { status: string; tags: string[] } }) {
const stable = useDeepMemo(filters);
useEffect(() => {
fetchReport(stable);
}, [stable]); // fires only when the CONTENTS change
}
It keeps the last value and only swaps the reference when a deep comparison finds a difference. The parent may recreate the object on every render all it likes — the child only reacts when the data changed.
Choose by where the object comes from
An object you create in the component → useMemo, cheaper. An object that
arrives ready-made and you do not control → useDeepMemo. Deep comparison
is not free: use it when the alternative is redoing bigger work.
The full list of utility hooks is in Hooks.
Recap
- Primitives copy by value; objects, arrays and functions copy by reference.
{} === {}isfalse. ✅ constfreezes the binding, not the contents.- Spread copies one level; for the rest there is
structuredClone. - A closure captures where the function was written — hence stale closures and the need for cleanup functions.
- React's dependency array compares with
Object.is: an inline object always fires. useMemofor objects you create; the SDK'suseDeepMemofor objects that arrive from outside.
📚 Canonical reference: MDN — JavaScript
➡️ Next page: Async: promise, await, fetch