058: Nested Data, Immutability, and Functional Updates
Learning outcomes
By the end of this lesson, you can:
- sketch and traverse arrays of objects containing nested objects and arrays;
- use optional chaining and
??when absence is expected; - update one nested record in an immutable style;
- copy every container on an updated path while retaining safe references elsewhere;
- separate lookup, transformation, and summary operations.
Retrieval warm-up
- What kind of copy does
{ ...product }make? - Which value wins when duplicate keys are spread into an object?
- Which array method is suited to updating one matching item while preserving array length?
Answers: shallow, the last written value, and map().
Vocabulary
- Data shape: the arrangement and expected types of fields in data (course term).
- Traverse: follow properties and array elements to reach a value (course term).
- Path: the sequence of containers from a root to a nested value (course term).
- Immutable-style update: return new changed containers rather than modify input containers (course term).
- Structural sharing: reuse unchanged objects while replacing changed paths (course term).
- Optional chaining: ?. short-circuits to undefined when the left side is nullish. — Source: MDN: Optional chaining
- Nullish coalescing: ?? yields right operand only when left is null/undefined. — Source: MDN: Nullish coalescing
- Normalization: organizing repeated entities by ID; an advanced design option, not today's requirement (course term).
- Optional chaining (official): "Optional chaining (?.) accesses a property without throwing if the reference is nullish." — Source: MDN: Optional chaining
- Immutable update (official): "An immutable update creates a new object/array with changes, leaving the original unchanged." — Source: MDN: Immutability and copying
Beginner mental model
Treat nested data like a set of labeled boxes. To read store.orders[0].customer.address.city, open one box at a time and verify the expected shape. Before coding, sketch it:
store (object) products (array) product (object) supplier (object) tags (array) orders (array) order (object) customer (object) address (object, optional) items (array of objects)
For an immutable-style update, do not clone the entire world and do not mutate the innermost box. Create a new box at every level on the path from the root to the changed value. Unchanged branches may safely keep their old references. That intentional reuse is structural sharing.
Optional chaining is for legitimate absence:
const city = order.customer.address?.city ?? "Collection point";
It is not a way to hide every bug. If all orders must have customer, order.customer?.name can conceal malformed data. Validate required fields and reserve ?. for optional ones.
Worked beginner example: inventory and orders
const store = {
products: [
{
id: "p1",
name: "Notebook",
price: 4,
stock: 12,
tags: ["study", "paper"],
supplier: { id: "s1", name: "Paper Co" },
},
{
id: "p3",
name: "Water Bottle",
price: 16,
stock: 7,
tags: ["travel", "reusable"],
supplier: { id: "s2", name: "Hydrate Ltd" },
},
],
orders: [
{
id: "o1",
customer: {
name: "Maya",
address: { city: "Chennai" },
},
items: [
{ productId: "p1", quantity: 2 },
{ productId: "p3", quantity: 1 },
],
},
{
id: "o2",
customer: { name: "Ravi" },
items: [{ productId: "p3", quantity: 2 }],
},
],
};
const travelProducts = store.products
.filter((product) => product.tags.includes("travel"))
.map((product) => product.name);
const orderCities = store.orders.map(
(order) => order.customer.address?.city ?? "Collection point",
);
function getOrderLines(store, orderId) {
const order = store.orders.find((item) => item.id === orderId);
if (!order) {
return [];
}
return order.items.map((item) => {
const product = store.products.find(
(item) => item.id === item.productId,
);
return {
name: product?.name ?? "Unknown product",
quantity: item.quantity,
lineTotal: product ? product.price * item.quantity : 0,
};
});
}
console.log(travelProducts);
console.log(orderCities);
console.log(getOrderLines(store, "o1"));
Output:
["Water Bottle"] ["Chennai", "Collection point"] [ { name: "Notebook", quantity: 2, lineTotal: 8 }, { name: "Water Bottle", quantity: 1, lineTotal: 16 } ]
The order stores stable product IDs rather than duplicate full product records. getOrderLines joins the two collections for display. It handles a missing order and a deleted/missing product explicitly.
Worked immutable-style update
Update the stock of one product while preserving store:
function setProductStock(store, productId, nextStock) {
return {
...store,
products: store.products.map((product) =>
product.id === productId
? { ...product, stock: nextStock }
: product,
),
};
}
const updatedStore = setProductStock(store, "p3", 5);
console.log(updatedStore.products[1].stock); // 5
console.log(store.products[1].stock); // 7
console.log(updatedStore === store); // false
console.log(updatedStore.products === store.products); // false
console.log(updatedStore.products[0] === store.products[0]); // true
console.log(updatedStore.orders === store.orders); // true
The root object, products array, and changed product are new. The unchanged Notebook and orders array retain references. This is correct structural sharing. "Immutable style" here means this function does not modify its input; it does not mean JavaScript automatically freezes returned values.
To update customer.address.city for one order, copy five relevant containers: root, orders array, matching order, customer, and address.
function setOrderCity(store, orderId, city) {
return {
...store,
orders: store.orders.map((order) =>
order.id === orderId
? {
...order,
customer: {
...order.customer,
address: {
...order.customer.address,
city,
},
},
}
: order,
),
};
}
Spreading undefined in an object literal contributes no properties in modern JavaScript, so this also creates an address when absent. For clarity, business code may explicitly use ...(order.customer.address ?? {}).
Intermediate example: remove an order line
Use map() to locate the order and filter() to remove its line. Copy only the changed path:
function removeOrderItem(store, orderId, productId) {
return {
...store,
orders: store.orders.map((order) => {
if (order.id !== orderId) {
return order;
}
return {
...order,
items: order.items.filter(
(item) => item.productId !== productId,
),
};
}),
};
}
const withoutNotebook = removeOrderItem(store, "o1", "p1");
console.log(withoutNotebook.orders[0].items);
// [{ productId: "p3", quantity: 1 }]
console.log(store.orders[0].items.length); // 2
The function remains predictable: input store in, new store out. In a production system, decide what a missing order or item should mean; returning an equivalent new root is one policy, while throwing is another.
Interview focus: pure functions and reference-safe updates
A pure function is deterministic for the same relevant inputs and has no observable side effects. It does not log, mutate an input object, write outside state, or depend on an uncontrolled clock/random source. Pure functions are easy to test because the test can compare input and output without resetting hidden state.
function addTax(price, rate) {
return price * (1 + rate);
}
function unsafeAddTag(product, tag) {
product.tags.push(tag); // mutates a caller-owned nested array
return product;
}
function addTag(product, tag) {
return { ...product, tags: [...product.tags, tag] };
}
The third function is pure for its inputs. A function may call an impure operation at the application boundary while keeping the calculation pure; purity is about observable behavior and dependencies, not about avoiding every local variable.
Follow-ups to practice: Which references must change in addTag? The product and
tags array. Which can remain shared? Unchanged nested branches. Is a function that
returns a new array but mutates each object pure? No. A new outer container does
not undo mutation of its elements.
Optional advanced extension
Repeated .find() inside .map() is clear for small learning data but performs repeated searches. For larger data, create an ID lookup once:
const productById = new Map(
store.products.map((product) => [product.id, product]),
);
const lines = store.orders[0].items.map((item) => {
const product = productById.get(item.productId);
return {
name: product?.name ?? "Unknown product",
quantity: item.quantity,
};
});
This is an optional performance extension. Do not normalize small data prematurely when a direct traversal is easier to understand.
Common mistakes and debugging
- Losing the shape: sketch objects/arrays and log one level at a time.
- Wrong method at a level:
find()for one record,filter()for many/removal,map()for same-length updates. - Mutating before copying:
product.stock = 5already changes shared data. Build the copy directly. - Copying only the root:
{ ...store }still sharesproductsand each product. - Copying everything: deep cloning is wasteful and obscures which branch changed.
- Using
||for quantities:0 || fallbackreplaces valid zero. Use??when only absence deserves fallback. - Optional chaining required fields: it can convert malformed data into quiet
undefined. - Comparing names instead of IDs: names may change or duplicate; use stable IDs.
Best practices
- Sketch unfamiliar API-shaped data before traversing it.
- Validate required boundaries; use
?.for genuinely optional branches. - Copy every container on the changed path and only that path.
- Match array methods to intent and avoid giant nested reducers.
- Use stable IDs to relate products, orders, and cart lines.
- Write update functions as input-to-output transformations and verify originals remain unchanged.
Checkpoint
Before examining an implementation, name a target such as "quantity of product p3 in order o1." List the complete path and mark every container that must become new. Then mark unrelated branches that may remain shared. After coding, verify those predictions with === comparisons rather than only checking displayed values. A correct result with accidental mutation is still an incorrect state update; identity checks reveal that class of bug.
For traversal practice, deliberately remove one optional address and one required customer. Explain why fallback is appropriate for the address but validation or an error is appropriate for the missing customer.
Exercises
Core
Return all supplier names from store.products, then return the city for order o2 with fallback "Collection point".
const supplierNames = store.products.map(
(product) => product.supplier.name,
);
const order = store.orders.find((order) => order.id === "o2");
const city = order?.customer.address?.city ?? "Collection point";
console.log(supplierNames);
console.log(city);
Output:
["Paper Co", "Hydrate Ltd"] Collection point
Practice
Write addProductTag(store, productId, tag) that returns a new store and appends the tag only to the matching product.
function addProductTag(store, productId, tag) {
return {
...store,
products: store.products.map((product) =>
product.id === productId
? { ...product, tags: [...product.tags, tag] }
: product,
),
};
}
const nextStore = addProductTag(store, "p1", "bestseller");
console.log(nextStore.products[0].tags);
console.log(store.products[0].tags);
Output:
["study", "paper", "bestseller"] ["study", "paper"]
Professional Extension
Write setOrderItemQuantity(store, orderId, productId, quantity) with immutable-style updates. Preserve references for every unrelated order and item.
function setOrderItemQuantity(store, orderId, productId, quantity) {
return {
...store,
orders: store.orders.map((order) =>
order.id === orderId
? {
...order,
items: order.items.map((item) =>
item.productId === productId
? { ...item, quantity }
: item,
),
}
: order,
),
};
}
const next = setOrderItemQuantity(store, "o1", "p3", 4);
console.log(next.orders[0].items[1].quantity); // 4
console.log(store.orders[0].items[1].quantity); // 1
console.log(next.orders[1] === store.orders[1]); // true
console.log(next.orders[0].items[0] === store.orders[0].items[0]); // true
Recap
Describe the data path to an order city. What does optional chaining protect against? Which containers change when product stock changes? Why is sharing the unchanged orders array safe? Explain why immutable-style updating is more precise than blindly deep-cloning everything.
Official references
- MDN: Working with objects
- MDN: Optional chaining
- MDN: Nullish coalescing
- MDN: Spread syntax
- ECMA-262: Property Accessors
Reusable function utilities
These small utilities are useful only when their contracts are explicit. A memoized function caches by argument identity in this example; it is not a general serialization of arguments.
function memoize(fn) {
const root = { children: new Map(), hasValue: false, value: undefined };
return (...args) => {
let node = root;
for (const arg of args) {
if (!node.children.has(arg)) {
node.children.set(arg, {
children: new Map(), hasValue: false, value: undefined,
});
}
node = node.children.get(arg);
}
if (!node.hasValue) { node.hasValue = true; node.value = fn(...args); }
return node.value;
};
}
let calls = 0;
const square = memoize((number) => { calls += 1; return number * number; });
console.assert(square(3) === 9 && square(3) === 9 && calls === 1);
once runs at most once and returns the first result, including undefined:
function once(fn) {
let called = false;
let result;
return (...args) => {
if (!called) { called = true; result = fn(...args); }
return result;
};
}
let starts = 0;
const start = once(() => ++starts);
console.assert(start() === 1 && start() === 1 && starts === 1);
Currying collects arguments over calls. compose runs right to left, while
pipe (often called left-to-right compose) runs left to right:
const curry = (fn, collected = []) => (...args) => {
const all = [...collected, ...args];
return all.length >= fn.length ? fn(...all) : curry(fn, all);
};
const add = curry((a, b, c) => a + b + c);
console.assert(add(1)(2, 3) === 6);
const compose = (...fns) => (value) => fns.reduceRight((v, fn) => fn(v), value);
const pipe = (...fns) => (value) => fns.reduce((v, fn) => fn(v), value);
console.assert(compose((x) => x * 2, (x) => x + 1)(3) === 8);
console.assert(pipe((x) => x + 1, (x) => x * 2)(3) === 8);
Flatten recursively while retaining falsy values and handling empty arrays:
function flatten(values) {
return values.reduce(
(result, value) => result.concat(Array.isArray(value) ? flatten(value) : value),
[],
);
}
console.assert(JSON.stringify(flatten([1, [2, [0, false]], [], null])) ===
JSON.stringify([1, 2, 0, false, null]));
Interview questions: What does the memoization key mean for objects? How would
you bound or invalidate a cache? What happens if the wrapped once function
throws? Why does compose(f, g)(x) call g first? What is the time and space
cost of recursive flatten?
