Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 015 / 100

Immutable updates without libraries: a step-by-step guide

Modern JS has what you need.

Expona Admin10 min read

For years, developers reached for Immer or similar libraries to manage state immutably. But modern JavaScript gives us powerful built-in tools to handle immutable updates without libraries. Learning to use the spread operator, array methods like toSorted(), toReversed(), and with(), along with structuredClone(), means you can write clean, predictable state mutations that work across React, Vue, or plain vanilla applications.

The case for immutability in application state

Before we dive into the tools, let's establish why immutability matters. When you mutate objects directly, you lose the ability to detect changes efficiently. React can't tell if a state object changed because it relies on reference equality for optimization. In Svelte, MobX, or Redux, direct mutations break reactivity tracking. You end up with bugs where components don't re-render, selectors return stale results, or undo/redo breaks.

The good news: JavaScript now includes enough native features that you rarely need a third-party library. The patterns are straightforward once you know them.

Spread for objects and arrays

The spread operator is your foundation for shallow immutable updates. When you spread an object or array, you create a new reference while copying top-level properties.

typescript
// Objects: add or update a property immutably
const user = { id: 1, name: 'Alice', email: 'alice@example.com' };
const updatedUser = { ...user, email: 'newemail@example.com' };
// user.email is still 'alice@example.com'
// updatedUser.email is 'newemail@example.com'
// They are different objects: user !== updatedUser

// Arrays: add an element
const numbers = [1, 2, 3];
const withFour = [...numbers, 4];
// numbers is still [1, 2, 3]
// withFour is [1, 2, 3, 4]

// Arrays: remove an element by index
const items = ['a', 'b', 'c', 'd'];
const removed = items.filter((_, i) => i !== 1);
// removed is ['a', 'c', 'd']

// Arrays: update an element at index
const prices = [10, 20, 30];
const updated = prices.map((p, i) => i === 1 ? 25 : p);
// updated is [10, 25, 30]

In a real e-commerce cart scenario, you'd use this pattern constantly:

typescript
interface CartItem {
  productId: string;
  quantity: number;
  price: number;
}

interface Cart {
  items: CartItem[];
  totalPrice: number;
}

// Add item to cart
function addToCart(cart: Cart, item: CartItem): Cart {
  return {
    ...cart,
    items: [...cart.items, item],
    totalPrice: cart.totalPrice + item.price * item.quantity
  };
}

// Update quantity of an item
function updateQuantity(cart: Cart, productId: string, newQuantity: number): Cart {
  return {
    ...cart,
    items: cart.items.map(item =>
      item.productId === productId
        ? { ...item, quantity: newQuantity }
        : item
    ),
    totalPrice: cart.items.reduce((sum, item) => {
      if (item.productId === productId) {
        return sum - item.price * item.quantity + item.price * newQuantity;
      }
      return sum;
    }, cart.totalPrice)
  };
}

Spread works well for one or two levels, but watch out: it's only a shallow copy. If your object contains nested objects or arrays, the inner references remain the same.

toSorted, toReversed, and with() for array methods

Traditionally, JavaScript's array methods mutate in place. sort() and reverse() modify the original array. The newer toSorted(), toReversed(), and with() methods return new arrays instead, giving you immutability by default.

typescript
// toSorted: returns new array, leaves original unchanged
const scores = [45, 12, 89, 34];
const sorted = scores.toSorted((a, b) => b - a);
// scores is still [45, 12, 89, 34]
// sorted is [89, 45, 34, 12]

// toReversed: returns new reversed array
const items = ['apple', 'banana', 'cherry'];
const reversed = items.toReversed();
// items is still ['apple', 'banana', 'cherry']
// reversed is ['cherry', 'banana', 'apple']

// with: update a single element, returns new array
const colors = ['red', 'green', 'blue'];
const updated = colors.with(1, 'yellow');
// colors is still ['red', 'green', 'blue']
// updated is ['red', 'yellow', 'blue']

I use these methods frequently in sorting tables or filtering search results. They feel natural and reduce the mental overhead of tracking whether you're mutating:

typescript
interface Product {
  id: string;
  name: string;
  price: number;
  inStock: boolean;
}

// Sort products by price, immutably
const products: Product[] = [...];

function sortByPrice(items: Product[], ascending = true): Product[] {
  return items.toSorted((a, b) =>
    ascending ? a.price - b.price : b.price - a.price
  );
}

// Toggle out-of-stock items to the end
function deprioritizeOutOfStock(items: Product[]): Product[] {
  return items.toSorted((a, b) => {
    if (a.inStock === b.inStock) return 0;
    return a.inStock ? -1 : 1;
  });
}

One note: these methods have solid browser and Node.js support in recent versions (ES2023+), but check your target environment if you're supporting older platforms.

structuredClone for deep copies

The spread operator and array methods handle shallow updates well, but sometimes you need a deep copy—especially when your state has nested objects or arrays several levels deep. structuredClone() creates a complete, recursive copy of an object or array.

typescript
// Shallow copy pitfall
const user = {
  id: 1,
  profile: { name: 'Alice', address: { city: 'Colombo' } }
};
const shallow = { ...user };
shallow.profile.city = 'Kandy';
// user.profile.city is now 'Kandy' too! Both objects share the same profile reference.

// Deep copy with structuredClone
const deep = structuredClone(user);
deep.profile.address.city = 'Kandy';
// user.profile.address.city is still 'Colombo'

I reach for structuredClone() in scenarios like form state or nested configuration objects:

typescript
interface OrderState {
  id: string;
  customer: {
    name: string;
    contacts: Array<{ type: 'email' | 'phone'; value: string }>;
  };
  items: Array<{ productId: string; qty: number }>;
  metadata: Record;
}

function updateCustomerName(state: OrderState, newName: string): OrderState {
  const cloned = structuredClone(state);
  cloned.customer.name = newName;
  return cloned;
}

function addContact(state: OrderState, contact: { type: string; value: string }): OrderState {
  const cloned = structuredClone(state);
  cloned.customer.contacts.push(contact);
  return cloned;
}

One caveat: structuredClone() cannot clone functions, DOM nodes, or certain object types like Error. For most data structures in a business application, it works perfectly.

Object.freeze in development to catch mutations

Writing immutable code is a discipline. It's easy to accidentally slip and mutate an object directly, especially under time pressure. Object.freeze() helps catch these mistakes early by making objects immutable at runtime.

typescript
const user = Object.freeze({ id: 1, name: 'Alice' });
user.name = 'Bob'; // TypeError in strict mode, silently ignored otherwise
user.role = 'admin'; // Also prevented

// Deep freeze utility
function deepFreeze(obj: T): Readonly {
  Object.freeze(obj);
  Object.values(obj).forEach(value => {
    if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
      deepFreeze(value);
    }
  });
  return obj as Readonly;
}

const state = deepFreeze({
  user: { id: 1, name: 'Alice' },
  settings: { theme: 'dark' }
});

state.user.name = 'Bob'; // TypeError: Cannot assign to read only property 'name'

I typically use this pattern during development or testing to enforce immutability discipline. You might wrap your Redux reducers or state initializers with a deep freeze in development mode:

typescript
function createInitialState(env = process.env.NODE_ENV): AppState {
  const state: AppState = {
    user: null,
    items: [],
    loading: false
  };
  
  if (env === 'development') {
    return deepFreeze(state);
  }
  return state;
}

// In your reducer
function cartReducer(state: Cart, action: CartAction): Cart {
  if (env === 'development' && Object.isFrozen(state)) {
    // State is protected; any mutation attempt will throw
  }
  
  switch (action.type) {
    case 'ADD_ITEM':
      return { ...state, items: [...state.items, action.payload] };
    default:
      return state;
  }
}

Common mistakes

Spreading nested objects. Remember that spread is shallow. If you have deeply nested state and update a child, always restructure all the way up:

typescript
// Wrong: forgot to spread settings
const state = { user: { profile: { theme: 'dark' } } };
const wrong = { ...state, user: { ...state.user, profile: { theme: 'light' } } };
// Actually, the above is correct. The mistake would be:
const mistake = { ...state }; // This only copies the top level
mistake.user.profile.theme = 'light'; // Now you've mutated the original user object

// Right
const correct = {
  ...state,
  user: {
    ...state.user,
    profile: {
      ...state.user.profile,
      theme: 'light'
    }
  }
};

Using mutating array methods accidentally. Methods like sort(), reverse(), splice(), and push() modify in place. Always reach for toSorted(), toReversed(), spread patterns, or map()/filter() instead.

Forgetting structuredClone returns a new object. It's a function call, not an operator. You must assign the return value:

typescript
const original = { a: { b: 1 } };
structuredClone(original); // Does nothing; result is thrown away
original.a.b = 2; // original is now modified

const copy = structuredClone(original); // Correct
copy.a.b = 3; // original.a.b is still 2

Overusing Object.freeze. Freezing is powerful for catching bugs, but it comes with a performance cost and can make debugging harder (stack traces get noisy). Use it in development, tests, or sandboxed slices of state—not on every single object in production.

Key takeaways

  • Use the spread operator for shallow immutable updates to objects and arrays; it's fast and idiomatic.
  • Reach for toSorted(), toReversed(), and with() instead of mutating array methods like sort() and reverse().
  • When you have deeply nested state and need to update an inner value, spread all the way down the chain, or use structuredClone() to avoid building complex spreads.
  • structuredClone() gives you a complete deep copy, useful for form state, nested configurations, or any scenario where you can't track all nested references.
  • Use Object.freeze() in development or tests to catch accidental mutations and enforce immutability discipline; avoid it in production for performance.
  • Modern JavaScript's native tools are powerful enough for most applications—you rarely need external immutability libraries unless you're working with extremely deep or complex state structures.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles