Table of Contents
- What is State in React?
- How to Use State in React
- State Best Practices
- What is Lifecycle in React?
- React Lifecycle: From Class Components to Hooks
- Lifecycle Best Practices
- State vs. Props: Key Differences
- Common Pitfalls and How to Avoid Them
- Conclusion
- References
What is State in React?
State is a built-in React feature that allows components to store and manage dynamic data. Unlike static variables, state is reactive: when state changes, React automatically re-renders the component (and its children) to reflect the new data.
Think of state as a component’s “memory.” For example:
- A counter component might track the current count in state.
- A form component might store user input (e.g., name, email) in state.
- A todo app might manage a list of tasks in state.
State is private to the component that declares it. Parent components cannot directly modify a child’s state, and state cannot be accessed by other components unless explicitly passed as props.
How to Use State in React
React provides two primary ways to manage state in functional components: the useState hook (for simple state) and the useReducer hook (for complex state logic).
The useState Hook: Simple State Management
Introduced in React 16.8, useState is the most common way to add state to functional components. It returns a pair: the current state value and a function to update it (often called a “setter” function).
Syntax:
const [stateVariable, setStateFunction] = useState(initialValue);
stateVariable: The current value of the state.setStateFunction: A function to update the state (triggers a re-render).initialValue: The initial value of the state (optional; can be a primitive, object, array, etc.).
Example: Counter Component
Let’s build a simple counter to demonstrate useState:
import { useState } from 'react';
function Counter() {
// Initialize state: count starts at 0
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
export default Counter;
Breakdown:
useState(0)initializescountto0.setCountis the setter function. When the button is clicked,setCount(count + 1)updatescount, triggering a re-render.- The UI reflects the new
countvalue after each click.
Updating State with Previous State
If the new state depends on the previous state (e.g., incrementing a counter), always use the functional form of the setter to avoid race conditions:
// ❌ Risky: Relies on the current `count` value (may be stale)
setCount(count + 1);
// ✅ Safe: Uses the latest `prevCount` value
setCount((prevCount) => prevCount + 1);
The useReducer Hook: Complex State Logic
For state with multiple sub-values or complex update logic (e.g., nested objects, arrays), useReducer is often cleaner than useState. It follows the Redux pattern, using a “reducer” function to manage state transitions.
Syntax:
const [state, dispatch] = useReducer(reducer, initialState);
reducer: A function that takes the currentstateand anaction, then returns the new state.initialState: The initial state value.state: The current state.dispatch: A function to send “actions” to the reducer (triggers state updates).
Example: Todo List with useReducer
Let’s build a todo list where users can add/remove tasks. The state will be an array of todos, and we’ll use actions like 'ADD_TODO' and 'REMOVE_TODO' to update it:
import { useReducer } from 'react';
// Reducer function: Handles state updates based on actions
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return [...state, { id: Date.now(), text: action.text }];
case 'REMOVE_TODO':
return state.filter(todo => todo.id !== action.id);
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function TodoList() {
const [todos, dispatch] = useReducer(todoReducer, []); // Initial state: empty array
const [inputText, setInputText] = useState(''); // Track input with useState
const handleSubmit = (e) => {
e.preventDefault();
if (inputText.trim()) {
dispatch({ type: 'ADD_TODO', text: inputText }); // Dispatch ADD_TODO action
setInputText(''); // Clear input
}
};
return (
<div>
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Add a new todo..."
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.text}
<button onClick={() => dispatch({ type: 'REMOVE_TODO', id: todo.id })}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
export default TodoList;
Why useReducer here? The todo state has multiple update logic paths (add/remove), and useReducer centralizes this logic in a single reducer function, making the code easier to debug and scale.
State Best Practices
To avoid bugs and keep your code maintainable, follow these state best practices:
-
Never Mutate State Directly
State is immutable in React. Always use the setter function (setStateordispatch) to update state:// ❌ Bad: Directly mutates state (React won't detect the change) const [user, setUser] = useState({ name: 'Alice' }); user.name = 'Bob'; // No re-render! // ✅ Good: Returns a new object setUser((prevUser) => ({ ...prevUser, name: 'Bob' })); -
Keep State Minimal
Only store data in state that is needed for rendering. Derive computed values from state/props instead:const [todos, setTodos] = useState([]); // Derive "uncompletedTodos" instead of storing it in state const uncompletedTodos = todos.filter(todo => !todo.completed); -
Lift State Up When Needed
If multiple components need access to the same state, move the state to their closest common parent. Pass the state down as props and update it via callback functions:// Parent component holds the state function Parent() { const [count, setCount] = useState(0); return ( <div> <Child1 count={count} /> <Child2 onIncrement={() => setCount(prev => prev + 1)} /> </div> ); } -
Avoid Deeply Nested State
Nested state (e.g.,{ user: { address: { city: 'Paris' } } }) is hard to update. Flatten state or useuseReducerfor complex nested data.
What is Lifecycle in React?
A React component goes through a series of stages from creation to removal from the DOM—this is its lifecycle. Lifecycle methods (or hooks) let you run code at specific stages, such as:
- When the component is first rendered (“mounting”).
- When the component updates (due to state/props changes).
- When the component is removed from the DOM (“unmounting”).
Common use cases for lifecycle logic:
- Fetching data from an API on component mount.
- Subscribing to events (e.g., scroll, resize) when the component mounts and unsubscribing when it unmounts.
- Updating the document title when a state variable changes.
React Lifecycle: From Class Components to Hooks
Historically, React used class components with lifecycle methods like componentDidMount and componentWillUnmount. Today, hooks (introduced in React 16.8) are the preferred approach for lifecycle management.
Class Component Lifecycle Methods (Legacy)
Class components use lifecycle methods to hook into the component lifecycle. Key methods include:
| Stage | Method | Purpose |
|---|---|---|
| Mounting | componentDidMount | Runs once after the component is rendered to the DOM. Use for API calls, subscriptions, etc. |
| Updating | componentDidUpdate | Runs after the component updates (due to state/props changes). Use for side effects that depend on new props/state. |
| Unmounting | componentWillUnmount | Runs before the component is removed from the DOM. Use for cleanup (e.g., cancel subscriptions, remove event listeners). |
The useEffect Hook: Modern Lifecycle Management
useEffect is a hook that lets you run “side effects” (e.g., data fetching, subscriptions) in functional components. It replaces all class lifecycle methods in one flexible API.
Syntax:
useEffect(effectFunction, dependencies);
effectFunction: The code to run (side effect). Can return a cleanup function.dependencies: An array of values that the effect depends on. The effect runs:- Once on mount if the array is empty (
[]). - On mount and whenever dependencies change if the array has values (e.g.,
[count]). - After every render if the array is omitted.
- Once on mount if the array is empty (
Example 1: Fetch Data on Mount
Use useEffect with an empty dependency array to fetch data when the component first mounts:
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch user data from API
const fetchUser = async () => {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (error) {
console.error('Error fetching user:', error);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]); // Re-run effect if userId changes
if (loading) return <p>Loading...</p>;
if (!user) return <p>User not found</p>;
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
</div>
);
}
Example 2: Clean Up Side Effects
If your effect creates a subscription or event listener, return a cleanup function to avoid memory leaks:
import { useEffect } from 'react';
function WindowTracker() {
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
useEffect(() => {
// Effect: Update windowWidth on resize
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
// Cleanup: Remove event listener when component unmounts
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // Empty array: run once on mount
return <p>Window width: {windowWidth}</p>;
}
useEffect vs. Class Lifecycle Methods
| Class Method | useEffect Equivalent |
|---|---|
componentDidMount | useEffect(() => { ... }, []) |
componentDidUpdate | useEffect(() => { ... }, [dependency1, dep2]) |
componentWillUnmount | Return cleanup function from useEffect |
Lifecycle Best Practices
-
Always Clean Up Side Effects
Use the cleanup function inuseEffectto cancel subscriptions, remove event listeners, or abort pending API requests. This prevents memory leaks and stale state updates. -
Avoid Infinite Loops
IfuseEffectupdates state that’s in its dependency array, it will re-run indefinitely. Fix this by:- Removing unnecessary dependencies.
- Using
useCallback/useMemoto memoize functions/values.
-
Split Effects by Concern
Use multipleuseEffecthooks to separate unrelated side effects (e.g., one for data fetching, one for event listeners).
State vs. Props: Key Differences
| Feature | State | Props |
|---|---|---|
| Source | Defined internally in the component. | Passed from parent to child. |
| Mutability | Mutable (via setter functions). | Read-only (child cannot modify props). |
| Purpose | Manages dynamic, component-specific data. | Passes data/functions between components. |
| Update Trigger | Updates trigger component re-renders. | Updates (from parent) trigger re-renders. |
Common Pitfalls and How to Avoid Them
-
Directly Mutating State
Problem: React relies on state immutability to detect changes. Mutating state directly (e.g.,state.count = 5) won’t trigger a re-render.
Fix: Use setter functions (setState,dispatch) and return new objects/arrays. -
Stale Closures in
useEffect
Problem:useEffectcaptures the state/props values from the render it was defined in. If dependencies are missing, it may use outdated values.
Example:const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => { console.log(count); // Always logs 0 (stale closure) }, 1000); return () => clearInterval(timer); }, []); // Missing `count` in dependenciesFix: Add
countto the dependency array:[count]. -
Overusing State
Problem: Storing derived data (e.g., filtered lists) in state leads to redundancy and bugs.
Fix: Derive values from state/props directly:const [todos, setTodos] = useState([]); // ✅ Derive filteredTodos instead of storing it const filteredTodos = todos.filter(todo => !todo.completed);
Conclusion
State and Lifecycle are foundational to building dynamic React applications. State lets components manage internal data and respond to user interactions, while Lifecycle (via useEffect) handles side effects like data fetching and cleanup.
By mastering useState, useReducer, and useEffect, you’ll be able to build responsive, maintainable React apps. Remember to follow best practices like immutability, cleanup, and minimal state to avoid common pitfalls.
Practice is key! Experiment with small projects (e.g., a todo app, weather dashboard) to apply these concepts.