Lesson 29 of 54

Variables and State Management

Reducing Mutable State

Mutable state is the root of most bugs.

Race conditions, stale data, unexpected side effects—these all stem from state that can change unexpectedly. Every piece of mutable state is a variable that might not be what you think it is.

Reducing mutation doesn't mean eliminating it entirely. It means being intentional: mutate when you have to, but default to immutability.

The Problem with Mutation

Shared Mutable State

Javascript
const user = { name: 'Alice', role: 'admin' };

function downgradeUser(user) {
  user.role = 'viewer';  // Mutates the original!
}

function processUser(user) {
  // Assumes user is still admin
  if (user.role === 'admin') {
    // ...
  }
}

// Somewhere in the code
downgradeUser(user);
// ... later ...
processUser(user);  // Unexpected behavior!

The mutation in one place affects behavior in another. Now imagine this across hundreds of files.

Temporal Coupling

Javascript
let order = null;

function initializeOrder(items) {
  order = { items, status: 'draft' };
}

function submitOrder() {
  order.status = 'submitted';  // Assumes initializeOrder was called
}

function getOrderTotal() {
  return order.items.reduce(...);  // Assumes order exists
}

The functions depend on being called in the right sequence. Call them out of order and things break.

const by Default

In JavaScript/TypeScript, use const unless you need to reassign:

Javascript
// BAD: Using let when value doesn't change
let name = 'Alice';
let items = [1, 2, 3];

// GOOD: const signals intent
const name = 'Alice';
const items = [1, 2, 3];

Note: const only prevents reassignment, not mutation:

Javascript
const user = { name: 'Alice' };
user.name = 'Bob';  // This works! Object is mutated, not reassigned.

For true immutability, you need immutable patterns or libraries.

Immutable Update Patterns

Objects: Spread Syntax

Javascript
// Instead of mutation
user.name = 'Bob';
user.age = 31;

// Use spread for immutable update
const updatedUser = {
  ...user,
  name: 'Bob',
  age: 31,
};

Nested Objects

Javascript
// Deep mutation (dangerous)
user.address.city = 'NYC';

// Immutable deep update
const updatedUser = {
  ...user,
  address: {
    ...user.address,
    city: 'NYC',
  },
};

Arrays

Javascript
// Instead of push (mutates)
items.push(newItem);

// Spread (immutable)
const newItems = [...items, newItem];

// Instead of splice (mutates)
items.splice(index, 1);

// Filter (immutable)
const newItems = items.filter((_, i) => i !== index);

// Instead of sort (mutates)
items.sort();

// Spread then sort
const sorted = [...items].sort();

When Mutation Is Acceptable

Immutability has costs: more objects, more memory, potentially more GC. Mutation is appropriate when:

Local Mutation Within a Function

Javascript
function processItems(items) {
  const results = [];  // Mutable, but local
  for (const item of items) {
    results.push(transform(item));  // Local mutation is fine
  }
  return results;  // Result is immutable to callers
}

The mutation is contained. Callers see only the result.

Performance-Critical Code

Javascript
// In a hot loop with millions of iterations
function sum(numbers) {
  let total = 0;  // Mutation for performance
  for (let i = 0; i < numbers.length; i++) {
    total += numbers[i];
  }
  return total;
}

Creating millions of intermediate objects would be wasteful. Profile before optimizing, but mutation can be justified.

Builder Pattern

Javascript
class QueryBuilder {
  private query = {};
  
  where(field, value) {
    this.query.where = { ...this.query.where, [field]: value };
    return this;  // Mutation for fluent API
  }
  
  orderBy(field) {
    this.query.orderBy = field;
    return this;
  }
  
  build() {
    return { ...this.query };  // Return immutable copy
  }
}

const query = new QueryBuilder()
  .where('status', 'active')
  .orderBy('createdAt')
  .build();

Immutable by Convention

Without library support, enforce immutability by convention:

Object.freeze for True Immutability

Javascript
const config = Object.freeze({
  apiUrl: 'https://api.example.com',
  timeout: 5000,
});

config.timeout = 10000;  // Silently fails (or throws in strict mode)

Note: freeze is shallow. Deep freeze requires a utility:

Javascript
function deepFreeze(obj) {
  Object.keys(obj).forEach(key => {
    if (typeof obj[key] === 'object' && obj[key] !== null) {
      deepFreeze(obj[key]);
    }
  });
  return Object.freeze(obj);
}

TypeScript: Readonly Types

Typescript
interface User {
  readonly id: string;
  readonly name: string;
  readonly email: string;
}

// Or for all properties
type ImmutableUser = Readonly<User>;

// Compiler prevents mutation
const user: ImmutableUser = { id: '1', name: 'Alice', email: 'a@b.com' };
user.name = 'Bob';  // Error: Cannot assign to 'name' because it is a read-only property

as const

Typescript
const ROLES = ['admin', 'editor', 'viewer'] as const;
// Type is readonly ['admin', 'editor', 'viewer'], not string[]

ROLES.push('guest');  // Error: Property 'push' does not exist

Immutability Libraries

For complex applications, consider libraries:

Immer

Javascript
import { produce } from 'immer';

const user = { name: 'Alice', address: { city: 'NYC' } };

const updatedUser = produce(user, draft => {
  draft.address.city = 'LA';  // "Mutate" the draft
});
// user unchanged, updatedUser has new address

Immer lets you write mutation-style code but produces immutable results.

Immutable.js

Javascript
import { Map } from 'immutable';

const user = Map({ name: 'Alice', age: 30 });
const older = user.set('age', 31);

user.get('age');  // 30
older.get('age'); // 31

Different API, but guarantees immutability with structural sharing for performance.

The Benefits

Easier Debugging

When data is immutable, you know it hasn't changed since creation. No need to trace "who modified this?"

Simpler Testing

Immutable functions don't have side effects on inputs. Test input → output, done.

Undo/Redo for Free

Each state is a snapshot. Keep them in an array for history:

Javascript
const history = [initialState];
function update(newState) {
  history.push(newState);
}
function undo() {
  history.pop();
  return history[history.length - 1];
}

Concurrency Safety

Immutable data can be shared across threads (or async operations) without locks.


Key insight: Mutable state is where bugs hide. Use const by default. Prefer immutable update patterns (spread) over in-place mutation. Contain necessary mutation within function boundaries. Use TypeScript's readonly types or libraries like Immer for enforcement.