FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·058·9 MIN READ

058: Nested Data, Immutability, and Functional Updates

TOPICS COVERED: 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

Before working with nested data, retrieve three ideas that will matter throughout the lesson:

  1. What kind of copy does { ...product } make?
  2. Which value wins when duplicate keys are spread into an object?
  3. Which array method is suited to updating one matching item while preserving array length?

Answers: shallow, the last written value, and map(). Those answers are the foundation for the update patterns later: spreading copies only one container, later properties overwrite earlier ones, and map() gives us a same-length array with a deliberate choice about which item gets a new object.

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

Nested data is easiest to reason about when you treat it as a set of labeled boxes. To read store.orders[0].customer.address.city, open one box at a time: start at store, select orders, select the first order, and then follow customer, address, and city. At each step, the shape tells you what kind of value you should expect. Sketching that shape before coding is often faster than debugging a mistaken property path later:

text
store (object)
  products (array)
    product (object)
      supplier (object)
      tags (array)
  orders (array)
    order (object)
      customer (object)
        address (object, optional)
      items (array of objects)

That box model is useful for reading data, but it also explains updates. For an immutable-style update, do not clone the entire world and do not modify the innermost box in place. Create a new box at every level on the path from the root to the changed value. Branches outside that path can safely keep their old references. That intentional reuse is structural sharing.

Optional chaining is designed for legitimate absence:

js
const city = order.customer.address?.city ?? "Collection point";

If address is null or undefined, ?. stops the property access and produces undefined; ?? then supplies the fallback. This is not a general-purpose way to hide bugs. If every order must have a customer, writing order.customer?.name can turn malformed data into a quiet undefined. Validate required fields, and reserve ?. for branches that are genuinely optional.

Worked beginner example: inventory and orders

Here is a small store with two related collections. Products contain nested supplier and tag data. Orders refer to products by stable ID and contain their own customer and line-item data:

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

text
["Water Bottle"]
["Chennai", "Collection point"]
[
  { name: "Notebook", quantity: 2, lineTotal: 8 },
  { name: "Water Bottle", quantity: 1, lineTotal: 16 }
]

There are three separate operations here. filter() narrows the products, map() transforms the remaining products into names, and getOrderLines() first looks up an order and then transforms each line item. The order stores a stable product ID instead of duplicating the complete product record. The function joins the two collections when it needs display data, and it handles both a missing order and a deleted or otherwise missing product explicitly.

Worked immutable-style update

Suppose the stock for one product changes. The update should return a new store without changing the original one:

js
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, the products array, and the changed product are new. The unchanged Notebook object and the orders array retain their references. That is correct structural sharing: only the path that changed was replaced. The phrase "immutable style" describes what this function does with its input; JavaScript does not automatically freeze the objects it returns.

The same rule applies deeper in the structure. To update customer.address.city for one order, copy the five relevant containers: the root, the orders array, the matching order, the customer, and the address.

js
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 code also creates an address when one was absent. In business code, making that intent explicit can be clearer: ...(order.customer.address ?? {}).

Intermediate example: remove an order line

Removing a line combines two decisions. Use map() to find the order while keeping the orders array the same length, and use filter() inside that order to remove the matching line. Again, copy only the containers on the changed path:

js
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 contract remains straightforward: a store goes in and a new store comes out. The original line array is untouched. A production system still needs a policy for a missing order or item. Returning an equivalent new root is one valid policy; throwing an error is another. The choice belongs to the surrounding business contract, not to map() or filter() themselves.

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 or random source. This makes pure functions easy to test: a test can compare input and output without resetting hidden state.

js
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. An application can still call an impure operation at its boundary, such as logging or writing state, while keeping the calculation itself pure. Purity concerns observable behavior and uncontrolled dependencies; it does not require avoiding every local variable.

Follow-ups to practice: Which references must change in addTag? The product and the 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 the objects inside it.

Optional advanced extension

Repeated .find() inside .map() is easy to read for a small learning dataset, but it repeats the search for every line. For larger collections, build an ID lookup once and then retrieve products directly:

js
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, not a requirement to redesign every small object graph. Do not normalize small data prematurely when direct traversal is easier to understand. Choose the lookup structure when the repeated search is actually a meaningful cost or when the data model benefits from it.

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 = 5 already changes shared data. Build the copy directly.
  • Copying only the root: { ...store } still shares products and each product.
  • Copying everything: deep cloning is wasteful and obscures which branch changed.
  • Using || for quantities: 0 || fallback replaces 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.

When a result is wrong, inspect the boundary where the expectation first diverges. Check the data shape, then the method selected at each level, then the identities of the old and new containers with ===. A displayed value may look correct even though an earlier mutation changed the original state. Conversely, an unexpected fallback may indicate missing optional data or malformed required data; those are different problems and should not be debugged the same way.

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 checking only displayed values. A correct result with accidental mutation is still an incorrect state update; identity checks expose that class of bug.

For traversal practice, deliberately remove one optional address and one required customer. Explain why a fallback is appropriate for the address, while validation or an error is appropriate for the missing customer. This distinction is the reason optional chaining should be applied selectively rather than added to every property access.

Exercises

Core

Return all supplier names from store.products, then return the city for order o2 with fallback "Collection point".

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

text
["Paper Co", "Hydrate Ltd"]
Collection point

Practice

Write addProductTag(store, productId, tag) so that it returns a new store and appends the tag only to the matching product.

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

text
["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.

js
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

Reusable function utilities

These small utilities are useful only when their contracts are explicit. The memoized function below caches by argument identity in this example; it is not a general serialization of arguments. In particular, two separately created but structurally identical objects are different identity keys.

js
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 has a different contract: it runs the wrapped function at most once and returns the first result, including undefined:

js
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 across calls. compose runs functions from right to left, while pipe, often called left-to-right compose, runs them from left to right:

js
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);

Finally, flatten nested arrays recursively. The implementation retains falsy values such as 0 and false, and it also handles empty arrays:

js
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?

Reader page: /javascript/lesson/058/nested-data-immutability-and-functional-updates