Chapter 7: React State & Lifecycle
While props pass data from the outside, state represents a component's internal, private memory. Learn how state triggers re-renders, how React 18 batches updates, how to safely update nested objects and arrays immutably, and how the component lifecycle functions under the hood.
1. State vs. Props: The Core Distinctions
Understanding the boundaries between state and props is critical to designing predictable software:
| Criteria | Props (External Input) | State (Internal Memory) |
|---|---|---|
| Ownership | Passed from parent component; owned by parent. | Declared and managed privately within the component. |
| Mutability | Read-only (immutable) within the receiving component. | Mutable only via its setter function (e.g., setState). |
| Purpose | Configures a component, similar to function arguments. | Tracks dynamic values that change over time in response to user events. |
| Re-render Trigger | Component re-renders when parent passes new prop values. | Component re-renders whenever its state updater is called with a new value. |
2. Deep Dive into `useState` Mechanics
The useState hook declares a state variable. It returns an array with exactly two elements: the current state value, and a dispatcher function to update it:
import { useState } from 'react';
function Counter() {
// Array destructuring: [currentValue, setterFunction]
const [count, setCount] = useState(0);
return (
<div>
<p>Current count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}Why Functional Updaters Are Crucial:
Consider what happens if you call setCount(count + 1) three times consecutively:
function BrokenIncrement() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1); // count is 0 => schedules count to be 1
setCount(count + 1); // count is still 0 in this render scope => schedules count to be 1
setCount(count + 1); // count is still 0 => schedules count to be 1
// Final count after re-render: 1 (NOT 3!)
}
// ...
}Because state updates are batched and count is a snapshot captured at render time, all three calls read the same value (0). To resolve this, use the functional updater form:
function FixedIncrement() {
const [count, setCount] = useState(0);
function handleClick() {
// React passes the latest pending state to your updater function:
setCount(prev => prev + 1); // prev = 0 => returns 1
setCount(prev => prev + 1); // prev = 1 => returns 2
setCount(prev => prev + 1); // prev = 2 => returns 3
// Final count after re-render: 3!
}
// ...
}If calculating your initial state involves expensive operations (like parsing a large JSON string from localStorage), pass a function to useState:const [settings, setSettings] = useState(() => parseLargeData());
React will only execute this function during the initial mount, avoiding wasteful re-evaluations on subsequent renders!
3. Updating Objects and Arrays Immutably
In JavaScript, objects and arrays are reference types. If you modify an array using push() or an object viauser.name = 'Alex', the memory address does not change. React will compare prevObject === nextObject, conclude nothing changed, and skip re-rendering!
Updating Nested Objects with the Spread Operator:
const [user, setUser] = useState({
name: "Alex",
email: "alex@example.com",
preferences: { theme: "dark", notifications: true }
});
// ✅ Correct: Clone the outer object AND the nested object
function updateTheme(newTheme) {
setUser(prev => ({
...prev,
preferences: {
...prev.preferences,
theme: newTheme
}
}));
}Immutable Array Mutation Patterns:
| Operation | ❌ Avoid (Mutates Original Array) | ✅ Recommended (Returns New Array) |
|---|---|---|
| Adding Item | array.push(newItem) | [...prevArray, newItem] |
| Removing Item | array.splice(index, 1) | prevArray.filter(item => item.id !== targetId) |
| Modifying Item | array[index] = updatedItem | prevArray.map(item => item.id === targetId ? { ...item, ...updates } : item) |
4. The Three Phases of the Component Lifecycle
Every React component journeys through three distinct lifecycle phases:
1. Mounting (Birth)
The component is instantiated for the first time and inserted into the browser DOM. Initial state is established, the JSX renders, and mount side-effects execute.
2. Updating (Living)
The component re-renders when it receives new props or its internal state changes. The Virtual DOM diffs the changes, and specific DOM nodes update.
3. Unmounting (Death)
The component is removed from the DOM (e.g., navigating to another page). Cleanup functions run to abort pending network requests, clear timers, and remove event listeners.
Mapping Lifecycle Phases to `useEffect`:
import { useEffect } from 'react';
function LifecycleTracker({ userId }) {
useEffect(() => {
// 1. MOUNTING: Runs once when component appears on screen
console.log("Component mounted. Connecting WebSocket...");
const socket = connectToChat(userId);
// 3. UNMOUNTING: Runs when component is removed or before re-running effect
return () => {
console.log("Component unmounting. Disconnecting WebSocket...");
socket.disconnect();
};
}, [userId]); // 2. UPDATING: Re-runs only if userId prop changes!
return <div>Chat Active</div>;
}Frequently Asked Questions (FAQ)
Why does direct state mutation (e.g., state.count = 5) fail in React?
React relies on Object.is referential identity comparisons to know when state has changed. When you mutate an object or primitive directly, its reference remains identical, so React skips re-rendering. Furthermore, direct mutation bypasses React's internal scheduler, preventing the Virtual DOM from synchronizing with the browser DOM.
What is Automatic Batching in React 18?
Automatic batching groups multiple state updates into a single re-render to optimize performance. In React 17 and earlier, batching only occurred inside React event handlers. In React 18+, updates inside promises, setTimeout callbacks, and native event listeners are all automatically batched together.
When should I pass an updater function to setState (e.g., setCount(prev => prev + 1))?
You should always pass a functional updater when your next state depends directly on the previous state, or when updates occur inside asynchronous callbacks/closures where the captured state variable might be stale. The updater function guarantees access to the latest pending state value.
How does the component lifecycle map to the useEffect hook?
Mounting maps to useEffect with an empty dependency array []; updating maps to useEffect with specific dependencies [dep]; unmounting (cleanup) maps to the cleanup function returned inside the effect: return () => { ... }.
Explore Related Tools
Hand-picked utilities and calculators related to this tool.
