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.
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.
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.
props.items
, or calling methods that mutate incoming data—all signal design smells.