Lesson 3 of 8

Immutability

Working with immutable data structures and avoiding mutations

What is Immutability?

Immutability means that once data is created, it cannot be changed. Instead of modifying existing data, you create new data with the desired changes. This eliminates bugs from unexpected mutations and makes your code more predictable.

❌ The Problem with Mutations

// Mutation causes unexpected bugs
const user = { name: 'Alice', age: 25 };

function celebrateBirthday(person) {
  person.age++; // Mutates the original!
  return person;
}

celebrateBirthday(user);
console.log(user.age); // 26 - Original was changed!

// Arrays have the same problem
const numbers = [1, 2, 3];
const sorted = numbers.sort(); // Mutates original!
console.log(numbers); // [1, 2, 3] - Actually sorted!

// This leads to bugs that are hard to track
// "Who changed my data?"

Immutable Object Updates

// Use spread operator to create new objects
const user = { name: 'Alice', age: 25, city: 'NYC' };

// ❌ Mutation
user.age = 26;

// ✅ Immutable update
const updatedUser = { ...user, age: 26 };

console.log(user);        // { name: 'Alice', age: 25, city: 'NYC' }
console.log(updatedUser); // { name: 'Alice', age: 26, city: 'NYC' }

// Nested object updates
const state = {
  user: { name: 'Alice', address: { city: 'NYC', zip: '10001' } },
  settings: { theme: 'dark' },
};

// ✅ Immutably update nested property
const newState = {
  ...state,
  user: {
    ...state.user,
    address: {
      ...state.user.address,
      city: 'Boston',
    },
  },
};

// Original unchanged
console.log(state.user.address.city); // 'NYC'
console.log(newState.user.address.city); // 'Boston'

Immutable Array Updates

const todos = [
  { id: 1, text: 'Learn FP', done: false },
  { id: 2, text: 'Practice', done: false },
];

// ADD - Use spread or concat
const withNew = [...todos, { id: 3, text: 'Master it', done: false }];
const withNewAlt = todos.concat({ id: 3, text: 'Master it', done: false });

// REMOVE - Use filter
const without2 = todos.filter(todo => todo.id !== 2);

// UPDATE - Use map
const toggleDone = todos.map(todo =>
  todo.id === 1 ? { ...todo, done: true } : todo
);

// INSERT AT INDEX
const insertAt = (arr, index, item) => [
  ...arr.slice(0, index),
  item,
  ...arr.slice(index),
];

// REMOVE AT INDEX
const removeAt = (arr, index) => [
  ...arr.slice(0, index),
  ...arr.slice(index + 1),
];

// REPLACE AT INDEX
const replaceAt = (arr, index, item) => [
  ...arr.slice(0, index),
  item,
  ...arr.slice(index + 1),
];

// SORT (without mutation)
const sorted = [...numbers].sort((a, b) => a - b);

// REVERSE (without mutation)
const reversed = [...numbers].reverse();

Mutating vs Non-Mutating Methods

Mutating ❌ Non-Mutating ✅
push() [...arr, item]
pop() arr.slice(0, -1)
shift() arr.slice(1)
unshift() [item, ...arr]
sort() [...arr].sort() or toSorted()
reverse() [...arr].reverse() or toReversed()
splice() toSpliced() or slice + spread
arr[i] = x with(i, x) or map

Note: toSorted(), toReversed(), toSpliced(), and with() are new ES2023 methods that return new arrays.

Object.freeze and const

// const prevents reassignment, NOT mutation
const arr = [1, 2, 3];
arr = [4, 5, 6]; // ❌ TypeError: Assignment to constant
arr.push(4);     // ✅ Works! Array is mutated

const obj = { name: 'Alice' };
obj = { name: 'Bob' }; // ❌ TypeError
obj.name = 'Bob';      // ✅ Works! Object is mutated

// Object.freeze prevents mutations (shallow)
const frozen = Object.freeze({ name: 'Alice', age: 25 });
frozen.age = 26; // Silently fails (or throws in strict mode)
console.log(frozen.age); // 25

// But freeze is shallow!
const deepObj = Object.freeze({
  user: { name: 'Alice' },
});
deepObj.user.name = 'Bob'; // ✅ Works! Nested object not frozen

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

Immutability in React

// React relies on immutability for change detection
function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'Learn FP', done: false },
  ]);

  // ❌ WRONG: Mutating state directly
  const toggleWrong = (id) => {
    const todo = todos.find(t => t.id === id);
    todo.done = !todo.done; // Mutation!
    setTodos(todos); // Same reference, React won't re-render
  };

  // ✅ CORRECT: Immutable update
  const toggleCorrect = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    ));
  };

  // ✅ Add todo
  const addTodo = (text) => {
    setTodos([...todos, { id: Date.now(), text, done: false }]);
  };

  // ✅ Remove todo
  const removeTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };
}

// useReducer with immutable updates
function reducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return { ...state, todos: [...state.todos, action.payload] };
    case 'REMOVE_TODO':
      return { 
        ...state, 
        todos: state.todos.filter(t => t.id !== action.payload) 
      };
    default:
      return state;
  }
}

📖 React: Updating Objects in State →

Immer for Easy Immutability

import { produce } from 'immer';

const state = {
  user: { name: 'Alice', address: { city: 'NYC' } },
  todos: [{ id: 1, text: 'Learn', done: false }],
};

// Write "mutations" that produce immutable updates!
const newState = produce(state, draft => {
  draft.user.address.city = 'Boston';
  draft.todos.push({ id: 2, text: 'Practice', done: false });
  draft.todos[0].done = true;
});

// Original unchanged
console.log(state.user.address.city); // 'NYC'
console.log(newState.user.address.city); // 'Boston'

// React + Immer
import { useImmer } from 'use-immer';

function App() {
  const [state, updateState] = useImmer({ count: 0 });
  
  return (
    <button onClick={() => updateState(draft => { draft.count++ })}>
      {state.count}
    </button>
  );
}

📖 Immer Documentation →

✅ Immutability Best Practices

  • • Always use spread operator for object/array updates
  • • Prefer map, filter, reduce over mutating methods
  • • Use toSorted(), toReversed() for non-mutating sort/reverse
  • • Consider Immer for complex nested updates
  • • Never mutate function parameters
  • • Remember: const prevents reassignment, not mutation