Three dots, two meanings
... is spread when it expands a collection into pieces, and rest when it collects pieces into an array.
Which one it is depends entirely on position. On the right side of an assignment or inside a literal it spreads, and in a parameter list or a destructuring pattern it collects.
- Spread into a new array or object, as in
[...list, 4]and{ ...defaults, port: 3000 }. Later object keys overwrite earlier ones, which makes spread the standard way to copy with changes. - Rest in parameters, as in
function sum(...nums), packs every argument into a real array.
Rest in a pattern is the same idea, so const [head, ...tail] = list and const { id, ...others } = obj both collect the leftovers.
Spread copies are shallow, meaning nested objects are still shared. Hold that thought for the capstone in lesson 10-3.
Spread also works on any iterable rather than only arrays, which connects back to unit 7. That is why [...someString], [...someSet], and [...yourIterableObject] all work.
These few characters are the daily texture of modern JavaScript, covering copying state without mutating it, variadic helpers, and surviving missing data with ?. and ??. They also headline the modern-JavaScript round of interviews.
Spread and rest in one program
A changed copy and a variadic function.
const defaults = { host: "localhost", port: 80 }; const config = { ...defaults, port: 3000 }; console.log(config.host + ":" + config.port); console.log(defaults.port); function sum(...nums) { return nums.reduce((total, n) => total + n, 0); } console.log(sum(1, 2, 3, 4));
Output
localhost:3000 80 10
Spread builds a changed copy without touching the original, and rest lets sum accept any number of arguments.
The third line proves defaults was not mutated, which is the whole reason to prefer this over assigning to defaults.port.
Key order decides the winner. { port: 3000, ...defaults } would produce port 80 instead, since the spread comes last and overwrites.
nums is a real array, so reduce, map, and length all work. The old arguments object was array-like and needed converting, which is why rest replaced it.
sum() with no arguments returns 0, because reduce uses the supplied initial value on an empty array. Omitting that 0 would throw instead.
No. copy.user is the same object as state.user.
Spread is a shallow copy. It duplicates the top-level slots, and a slot holding an object copies the reference rather than the object.
So mutating copy.user.name changes state.user.name too, and copy.user === state.user is true.
The top level really is independent, which is the part that makes this subtle. Assigning copy.title = "new" leaves state.title alone, so the copy behaves correctly right up to the first nested mutation.
This is the number one cause of "why did my React state not update" bugs. A component holding the same nested object sees no change, because the reference it compares is identical.
The usual fix is to spread at every level you intend to change, as in { ...state, user: { ...state.user, name: "new" } }.
The deep-copy tool, structuredClone, arrives in lesson 10-3.
Safe access: ?. and ??
Two operators for data that might not be there.
Optional chaining user.profile?.email returns undefined instead of crashing when profile is null or undefined.
It works for calls too, so callback?.() invokes the function only if there is one, and for indexes, as in list?.[0].
The short-circuit is worth understanding precisely. When the left side is nullish, the entire rest of the chain is skipped, so a?.b.c.d does not throw even though only one ?. appears.
Nullish coalescing value ?? fallback uses the fallback only for null and undefined.
The interview trap is ?? versus ||. The old || falls back on any falsy value, so a legitimate 0, "", or false gets replaced.
?? respects those values, matching how destructuring defaults treated undefined in lesson 8-1. Reach for || only when every falsy value genuinely means absent.
Zero is a real value
The user set volume to 0 on purpose.
const settings = { volume: 0 }; console.log(settings.volume || 50); console.log(settings.volume ?? 50); const user = { name: "Ada" }; console.log(user.profile?.email); console.log(user.profile?.email ?? "no email");
Output
50 0 undefined no email
|| stomps on the 0, ?? keeps it, and ?. survives the missing profile.
The first line is a real bug in disguise. Silently un-muting a user's audio is exactly the kind of failure that reaches production, because the code looks reasonable.
Without the ?., user.profile.email would throw a TypeError, since user.profile is undefined.
The third line prints undefined because a short-circuited chain evaluates to undefined, never to null, regardless of which one triggered it.
Combining both operators is the idiomatic form for optional data, and the last line is the shape you will write most often.
Mixing ?? with || or && directly is a syntax error, so a ?? b || c has to be parenthesized. The language forces the ambiguity to be resolved in the source.
The expression that preserves it is query ?? "all".
"" is falsy, so query || "all" swaps it for "all" and throws away what the user typed.
"" is not null or undefined, so ?? keeps it, and an empty search stays an empty search.
That difference is not academic. An empty string usually means the user cleared the box on purpose, and replacing it with a default silently changes the query they asked for.
Reach for ?? whenever 0, "", or false are meaningful values, which covers counts, coordinates, prices, text inputs, and boolean flags.
|| is still correct when any falsy value means absent, and name || "Anonymous" is a fair use. The habit worth building is choosing deliberately rather than typing || by reflex.
merge and first
One function using spread, one using rest.
function merge(base, overrides) { return { ...base, ...overrides }; } function first(...items) { return items[0] ?? "none"; } const merged = merge({ a: 1, b: 2 }, { b: 9 }); console.log(merged.a + " " + merged.b); console.log(first("x", "y")); console.log(first());
Output
1 9 x none
merge returns { ...base, ...overrides }, and order matters because later spreads overwrite earlier keys.
first gets an array from rest, so items[0] ?? "none" is the whole body.
Neither input to merge is mutated, which is the property that makes it safe to call anywhere. Both arguments stay exactly as the caller left them.
merge is shallow like every spread, so a nested object in base is shared with the result.
?? is the right operator in first rather than ||, since first(0) should return 0 and not "none". items[0] is undefined only when nothing was passed.