The Cost of Mutable State
Shared mutable state is the root of cascading bugs. Learn why mutation is expensive, what it costs in testing and refactoring, and practical patterns to reduce it.
Every developer has spent hours debugging a subtle bug where an object was modified in one place, and the consequences cascaded through the codebase. By the time you found it, five functions were touching the same object, three of them mutating it, and you had no idea which one broke it.
Mutable state is not free. The cost hides in debugging time, test complexity, refactoring friction, and subtle concurrency bugs. Let’s look at what you’re actually paying.
The Problem: Invisible State Changes
// A simple order object
const order = {
total: 100,
items: [{ id: 1, price: 50 }, { id: 2, price: 50 }],
customer: { id: 1, email: "[email protected]" },
};
// Then somewhere in your codebase...
function applyDiscount(order, percent) {
order.total = order.total * (1 - percent / 100); // Mutation 1
}
// And somewhere else...
function addShippingFee(order, fee) {
order.total = order.total + fee; // Mutation 2
}
// And later...
function calculateTax(order, taxRate) {
order.total = order.total * (1 + taxRate); // Mutation 3
}
function logOrder(order) {
console.log("Order total:", order.total); // What number is this?
}
// In your main flow:
applyDiscount(order, 10);
addShippingFee(order, 15);
calculateTax(order, 0.1);
logOrder(order); // 100 * 0.9 + 15 * 1.1 = 115.5? Or something else?
The real cost: You can’t reason about what order.total is at any given line without tracing through every mutation that came before. And if the code runs async or in a loop, mutation order becomes unpredictable.
Testing Mutable State is a Nightmare
Mutable state explodes test complexity:
// ❌ Mutable version — test isolation nightmare
class ShoppingCart {
private items: CartItem[] = [];
addItem(item: CartItem) {
this.items.push(item);
}
applyDiscount(percent: number) {
this.items.forEach(item => {
item.price = item.price * (1 - percent / 100); // Mutation!
});
}
removeItem(id: number) {
this.items = this.items.filter(item => item.id !== id);
}
}
// Test 1: Add item
const cart1 = new ShoppingCart();
cart1.addItem({ id: 1, price: 100 });
expect(cart1.total()).toBe(100);
// Test 2: Apply discount
const cart2 = new ShoppingCart();
cart2.addItem({ id: 1, price: 100 });
cart2.applyDiscount(10);
expect(cart2.total()).toBe(90);
// Test 3: Remove item
const cart3 = new ShoppingCart();
cart3.addItem({ id: 1, price: 100 });
cart3.addItem({ id: 2, price: 50 });
cart3.removeItem(1);
expect(cart3.total()).toBe(50);
// ✗ Each test requires building state from scratch
// ✗ Tests are slow and repetitive
// ✗ Ordering matters — if one mutation breaks, everything downstream fails
// ✗ Hard to test "what if we discount twice?" without modifying the test
Compare to immutable:
// ✅ Immutable version — clean test setup
class ShoppingCart {
constructor(private items: CartItem[]) {}
addItem(item: CartItem): ShoppingCart {
return new ShoppingCart([...this.items, item]);
}
applyDiscount(percent: number): ShoppingCart {
return new ShoppingCart(
this.items.map(item => ({
...item,
price: item.price * (1 - percent / 100),
}))
);
}
removeItem(id: number): ShoppingCart {
return new ShoppingCart(
this.items.filter(item => item.id !== id)
);
}
total(): number {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
}
// ✅ Compose transformations easily
const empty = new ShoppingCart([]);
const withItem = empty.addItem({ id: 1, price: 100 });
const discounted = withItem.applyDiscount(10);
const removed = discounted.removeItem(1);
// ✅ Single test for each operation, works in any order
expect(empty.addItem({ id: 1, price: 100 }).total()).toBe(100);
expect(empty.addItem({ id: 1, price: 100 }).applyDiscount(10).total()).toBe(90);
expect(
empty
.addItem({ id: 1, price: 100 })
.addItem({ id: 2, price: 50 })
.removeItem(1)
.total()
).toBe(50);
// ✅ Tests are fast, declarative, and isolated
Refactoring Mutable State is Fragile
When you refactor code that mutates objects, you must update every place that depends on the mutation:
// Original code:
function processOrder(order: Order) {
order.status = "processing"; // Mutation
await sendConfirmationEmail(order); // Depends on mutation
}
// You want to refactor: move email into a separate flow
// Problem: sendConfirmationEmail might need the "processing" status
// So you end up with:
function processOrder(order: Order) {
order.status = "processing"; // Still need mutation
await sendConfirmationEmail(order); // Still depends on it
}
// And somewhere else:
function handleOrderCreated(event: OrderCreatedEvent) {
const order = fetchOrder(event.orderId);
processOrder(order); // Mutates order
// Now order.status is "processing" — but where did this mutation happen?
// It's hard to refactor because you don't know what else depends on it
}
With immutable data, refactoring is explicit:
// Immutable version:
function processOrder(order: Order): { order: Order; email: Email } {
const updated = order.withStatus("processing");
const email = buildConfirmationEmail(updated);
return { order: updated, email };
}
// Refactoring is safe — no hidden dependencies
async function handleOrderCreated(event: OrderCreatedEvent) {
const order = await fetchOrder(event.orderId);
const { order: processed, email } = processOrder(order);
// Now it's clear: processOrder returns the new state AND the side effects
// Easy to reason about, easy to refactor
await sendEmail(email);
await saveOrder(processed);
}
Concurrency + Mutable State = Subtle Bugs
Multi-threaded or async code + mutation = race conditions:
// ❌ Race condition waiting to happen
class User {
balance: number = 1000;
withdraw(amount: number) {
// Thread A checks balance: 1000 > 100 ✓
if (this.balance > amount) {
// Thread B also withdraws, reducing balance to 900
// Thread A doesn't know about this
this.balance -= amount; // Now balance might be negative
}
}
}
const user = new User();
// Two concurrent withdrawals of 600 each
Promise.all([
user.withdraw(600),
user.withdraw(600),
]);
// Result: balance = -200, even though we checked it was sufficient
Immutable data prevents this category of bug entirely:
// ✅ No race conditions possible — state can't be mutated
class User {
constructor(readonly balance: number) {}
withdraw(amount: number): { user: User; success: boolean } {
if (this.balance >= amount) {
return { user: new User(this.balance - amount), success: true };
}
return { user: this, success: false };
}
}
// Concurrent operations return independent User instances
const user = new User(1000);
const [result1, result2] = await Promise.all([
user.withdraw(600),
user.withdraw(600),
]);
// One succeeds, one fails — no corrupted state
console.log(result1.success); // true, balance = 400
console.log(result2.success); // false, balance = 1000
Debugging State Mutations is Painful
With mutable state, the bug could be anywhere:
// Which of these mutations caused the bug?
order.total = 100; // Line 23
order.total = 95; // Line 67 (discount)
order.total = 110; // Line 89 (shipping)
order.total = 121; // Line 102 (tax)
order.total = 55; // Line 234 (what happened here?!)
// You have to step through every mutation to find it.
// With 20 functions touching the object, that's hours of debugging.
With immutable data, mutations are explicit and traceable:
const order = Order.create({ total: 100 });
const discounted = order.withDiscount(0.05); // Returns new Order
const withShipping = discounted.withShipping(15); // Returns new Order
const taxed = withShipping.withTax(0.1); // Returns new Order
// Each line is a transformation. If the result is wrong, you know exactly
// which transformation caused it. No time machine debugging needed.
Patterns to Reduce Mutable State
1. Immutable Data Structures
// ❌ Mutable
class Account {
balance: number = 0;
transactions: Transaction[] = [];
deposit(amount: number) {
this.balance += amount;
this.transactions.push({ type: "deposit", amount });
}
}
// ✅ Immutable
class Account {
constructor(
readonly balance: number = 0,
readonly transactions: readonly Transaction[] = []
) {}
deposit(amount: number): Account {
return new Account(
this.balance + amount,
[...this.transactions, { type: "deposit", amount }]
);
}
}
2. Copy-on-Write
When you must use objects that are shared, copy before mutating:
function updateUserEmail(user: User, newEmail: string): User {
// Don't mutate the input
// Instead, create a new object with the change
return {
...user,
email: newEmail,
updatedAt: new Date(),
};
}
const alice = { id: 1, email: "[email protected]", updatedAt: new Date() };
const aliceUpdated = updateUserEmail(alice, "[email protected]");
// Original is untouched
console.log(alice.email); // "[email protected]"
console.log(aliceUpdated.email); // "[email protected]"
3. Functional Updates
For complex objects, use update functions instead of setters:
// ❌ Mutations spread across codebase
user.profile.address.city = "Paris";
user.profile.company.name = "Acme";
user.profile.email = "[email protected]";
// ✅ Explicit update function
function updateUser(user: User, updates: Partial<User>): User {
return { ...user, ...updates };
}
const updated = updateUser(user, {
email: "[email protected]",
// profile changes would need nested logic here
});
For deeply nested objects, use a lens library (or build your own):
import { set } from "lens";
// Composable, type-safe updates
const userEmailLens = lens((u: User) => u.profile.email);
const updated = set(userEmailLens, "[email protected]", user);
4. Snapshots for Undo/Redo
Immutability makes undo trivial:
class Editor {
private history: EditorState[] = [];
private currentIndex: number = -1;
execute(action: EditorAction): void {
const newState = applyAction(this.currentState, action);
this.currentIndex++;
this.history[this.currentIndex] = newState;
// Discard any redo history
this.history.splice(this.currentIndex + 1);
}
undo(): void {
if (this.currentIndex > 0) {
this.currentIndex--;
}
}
redo(): void {
if (this.currentIndex < this.history.length - 1) {
this.currentIndex++;
}
}
get currentState(): EditorState {
return this.history[this.currentIndex];
}
}
// Undo/redo works perfectly because each state is a complete snapshot
editor.execute({ type: "insertText", text: "hello" });
editor.execute({ type: "insertText", text: " world" });
editor.undo(); // Previous state is just stored, not reconstructed
editor.redo(); // Next state is just stored, not reconstructed
Practical Guidelines
- Default to immutability — Only mutate when performance data proves you need to
- Minimize the scope of mutation — If you must mutate, do it in a small, isolated function
- Never expose mutable state — Return new objects, not references to internal state
- Use
readonlyin TypeScript — Signal intent and prevent accidental mutations
// Good — makes intent clear
class Repository {
readonly items: readonly Item[] = [];
}
// Prevents this:
repo.items.push(newItem); // TypeScript error: push is not on readonly[]
- Test immutable transformations, not state — Assert on returned values, not side effects
The Trade-off
Immutability has costs too:
- Memory overhead (copying data vs. mutating in place)
- GC pressure in languages without structural sharing
- Slightly more boilerplate
But in practice, these costs are dwarfed by the debugging, refactoring, and concurrency bugs you avoid.
“The best way to avoid bugs is to make bugs impossible.” — Simple Made Easy, Rich Hickey
Shared mutable state makes entire categories of bugs possible. Immutability makes them impossible.
Start with immutability as your default. When profiling shows mutation is necessary, use it surgically — in isolated, well-tested functions. The fewer places that mutate state, the fewer places you’ll have to debug when things go wrong.