Prop Immutability

Treat props as immutable inputs—never mutate them or depend on external mutation.

Why?  Preserves one‑way data flow and enables predictable rendering.

React is built on a one‑way data flow: parents pass values to children, children never reach back up to change those values in place. When a component mutates a prop—or relies on a parent mutating an object it passed down—React’s change detection can’t see what happened. The UI drifts out of sync, memoization breaks, and seemingly random bugs appear. Newcomers often mutate props “just to toggle a flag” or to push into an array, not realizing they’re sidestepping React’s render cycle.

Correct Example

type Todo = { id: number; text: string; done: boolean };

function TodoItem({
  todo,
  onToggle,
}: {
  todo: Todo;
  onToggle: (id: number) => void;
}) {
  return (
    <label className="flex items-center gap-2">
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => onToggle(todo.id)}
      />
      <span className={todo.done ? "line-through" : ""}>{todo.text}</span>
    </label>
  );
}

export default function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [todos, setTodos] = useState(initialTodos);

  const toggle = (id: number) =>
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );

  return (
    <ul className="space-y-2">
      {todos.map((t) => (
        <li key={t.id}>
          <TodoItem todo={t} onToggle={toggle} />
        </li>
      ))}
    </ul>
  );
}

What’s happening? TodoItem never mutates its todo prop. Instead, it notifies the parent (onToggle), which replaces the relevant object with a new one—so React sees a new reference and re‑renders the right bits.

Incorrect Example

function TodoItemMutable({
  todo,
}: {
  todo: { id: number; text: string; done: boolean };
}) {
  // ❌ Mutating the prop directly
  const flip = () => {
    todo.done = !todo.done; // mutation in place
  };

  return (
    <label>
      <input type="checkbox" checked={todo.done} onChange={flip} />
      {todo.text}
    </label>
  );
}

export default function TodoListBroken({
  initialTodos,
}: {
  initialTodos: Todo[];
}) {
  // Parent never sees a *new* object—React thinks nothing changed.
  const [ignored, forceRerender] = useReducer((x) => x + 1, 0);

  return (
    <>
      {initialTodos.map((t) => (
        <TodoItemMutable key={t.id} todo={t} />
      ))}
      <button onClick={forceRerender}>Force refresh</button>
    </>
  );
}

Outcome: The checkbox toggles internally, but the parent state never updates. Other components relying on initialTodos stay stale unless you force a rerender—exactly the kind of hidden bug immutability avoids.

Key Takeaways

  • Props are read‑only: Child components can react to them but must never mutate them.
  • Pass callbacks, not mutations: To change data, bubble an intent upward via a function prop; let the owner replace objects immutably.
  • Immutable updates = predictable UI: New references tell React “something changed,” enabling reliable diffing and memoization.
  • Red flags: Direct assignment to a prop object, pushing into props.items, or calling methods that mutate incoming data—all signal design smells.