About Level Up React Series
Level Up React is a series of in-depth articles designed to help React developers enhance their skills. We explore React’s internal mechanisms, best practices, design patterns, and advanced concepts. These articles are written for React developers who want to go beyond the basics and truly understand how React works under the hood.
Previous Articles in the Series
- React: Declarative vs Imperative Programming
- React: Deep Dive into React Elements
- React: React and React DOM architecture
- Level Up React: Functional programming in React
Introduction to state
State is one of the core concepts of React. It represents data that can change over time in a component. Unlike props which are passed by the parent component and are immutable from the child component’s perspective, state is internal to the component and can be modified.
The useState
hook is the main solution for managing local state in modern React functional components, forming the basis of interactivity in React applications.
useState hook basics
The useState
hook is a function that lets you add local state to a functional component. Let’s see how it works with a simple example:
import React, { useState } from "react";
function Counter() {
// Declares a state variable "count" initialized to 0
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click</button>
</div>
);
}
In this example:
useState(0)
initializes the state value to 0- The function returns an array of two elements that we destructure:
count
: the current state valuesetCount
: a function to update this state
Each time setCount
is called, React re-renders the component with the new value of count
.
The asynchronous nature of useState
A crucial and often misunderstood aspect of useState
is its asynchronous behavior. When you call the update function, React doesn’t immediately change the state value. Instead, it schedules this update, which can cause unexpected bugs.
function AsynchronousExample() {
const [count, setCount] = useState(0);
const handleClick = () => {
// This line doesn't immediately modify count
setCount(count + 1);
// Here, count is still its initial value
console.log(count); // Shows the previous value of count, not the new one
};
return <button onClick={handleClick}>Increment ({count})</button>;
}
To solve this issue, useState
offers an alternative form that accepts a function:
function FunctionalUpdateExample() {
const [count, setCount] = useState(0);
const handleClick = () => {
// Using a function that receives the previous state
setCount(prevCount => prevCount + 1);
// If we need to perform multiple updates
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
// This code increments count by 3
};
return <button onClick={handleClick}>Multiple increment ({count})</button>;
}
This functional form ensures that you always work with the most recent value of the state, even if React hasn’t re-rendered the component yet.
Impact on component rendering
Each state update triggers a new rendering of the component. This mechanism allows React to keep the interface up-to-date with the data.
function RenderExample() {
const [count, setCount] = useState(0);
console.log("Component rendered with count =", count);
return (
<button onClick={() => setCount(count + 1)}>Increment ({count})</button>
);
}
Each time you click the button, the console message appears, indicating that React has re-rendered the component with the new value of count
.
However, these frequent renders can cause performance issues if your component is complex or if multiple states change at the same time.
Lazy initialization
When you use useState
, it’s important to understand how React handles the initial value passed as a parameter. There is a crucial difference between directly passing a value and passing an initialization function.
Problem: Recalculation on each render
When you directly pass a value or an expression to useState
, this expression is evaluated on each component render:
function ExpensiveInitExample() {
// ❌ Problematic: complexCalculation() is executed on EVERY render
const [value, setValue] = useState(complexCalculation());
console.log("Component rendered");
return (
<div>
<p>Value: {value}</p>
<button onClick={() => setValue(value + 1)}>Increment</button>
</div>
);
}
In this example, even though we only care about the initial value, complexCalculation()
will be called on every component render, not just during initialization. This can significantly impact performance if this function is resource-intensive.
Solution: Initialization function
To solve this problem, React allows passing an initialization function to useState
. This function will only be called once, during the first render:
function LazyInitExample() {
// ✅ Correct: the function is called only once, during the first render
const [value, setValue] = useState(() => {
console.log("Expensive calculation in progress...");
return complexCalculation();
});
console.log("Component rendered");
return (
<div>
<p>Value: {value}</p>
<button onClick={() => setValue(value + 1)}>Increment</button>
</div>
);
}
function complexCalculation() {
// Simulating an expensive calculation
for (let i = 0; i < 1000000; i++) {
// Intensive calculation
}
return 42;
}
In this version, complexCalculation
will only be executed once, during the initial mounting of the component, and not on each subsequent render. React simply uses the value returned by the function for initialization, then ignores this function on later renders.
When to use lazy initialization?
Lazy initialization is particularly useful in these situations:
- Expensive calculations (processing large amounts of data)
- Reading data from localStorage or Web Storage API
- Analyzing or transforming complex props
- Converting data formats
Managing complex objects
When your state is an object or an array, you need to be careful to respect the principle of immutability:
function ObjectStateExample() {
const [user, setUser] = useState({
name: "Alice",
age: 25,
preferences: {
theme: "dark",
notifications: true,
},
});
const updateTheme = newTheme => {
// ❌ Incorrect - Direct modification of state
// user.preferences.theme = newTheme;
// setUser(user); // Won't cause a re-render because the object reference is the same
// ✅ Correct - Creating a new object
setUser({
...user,
preferences: {
...user.preferences,
theme: newTheme,
},
});
};
return (
<div>
<p>Name: {user.name}</p>
<p>Age: {user.age}</p>
<p>Theme: {user.preferences.theme}</p>
<button onClick={() => updateTheme("light")}>Change theme</button>
</div>
);
}
React compares object references to determine if the state has changed. If you directly modify a state object, React won’t detect the change and won’t re-render the component.
Common pitfalls with useState
1. Closures and stale values
A frequent pitfall involves JavaScript “closures,” where a function captures a value at the time it’s defined:
function ClosureTrapExample() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setTimeout(() => {
// This function captures the value of count when it's defined
console.log("Count value:", count);
setCount(count + 1); // Increments with a potentially stale value
}, 2000);
return () => clearTimeout(timer);
}, []); // Empty dependency array means useEffect runs only once
return <p>Count: {count}</p>;
}
In this example, the function in setTimeout
captures the initial value of count
(which is 0). Even if count
changes in the meantime, the function will always use this initial value.
The solution is either to add count
to the dependency array or to use the functional form:
// Solution with functional form
useEffect(() => {
const timer = setTimeout(() => {
setCount(prevCount => prevCount + 1); // Always up to date
}, 2000);
return () => clearTimeout(timer);
}, []);
2. Multiple updates
If you need to make multiple state updates based on the same value, remember React’s batching behavior:
function MultipleBatchingExample() {
const [count, setCount] = useState(0);
const handleClick = () => {
// These three calls are batched and will only increment count by 1
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// To increment by 3, use the functional form:
// setCount(prev => prev + 1);
// setCount(prev => prev + 1);
// setCount(prev => prev + 1);
};
return <button onClick={handleClick}>Increment ({count})</button>;
}
Best practices for using useState
Separate concerns
Rather than having a large state object, prefer breaking down your state into independent logically related variables:
// ❌ Less ideal
const [state, setState] = useState({
name: "",
email: "",
isSubmitting: false,
errors: {},
});
// ✅ Better
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState({});
This approach allows for more targeted updates and improves code readability and maintenance. It’s important to note that this separation doesn’t prevent re-renders by itself - React will still re-render the component when any state changes, whether you use a single object or several separate state variables.
Naming convention
Adopt a consistent naming convention for your state variables and their update functions:
const [count, setCount] = useState(0);
const [isVisible, setIsVisible] = useState(true);
const [user, setUser] = useState(null);
The “set” prefix followed by the state variable name is the standard convention in React.
Extract complex logic
If your state update logic becomes complex, consider extracting it into separate functions:
function UserProfileForm() {
const [formData, setFormData] = useState({
username: "",
email: "",
bio: "",
});
const handleChange = e => {
const { name, value } = e.target;
setFormData(prevData => ({
...prevData,
[name]: value,
}));
};
const resetForm = () => {
setFormData({
username: "",
email: "",
bio: "",
});
};
// ... rest of the component
}
This approach makes your code more readable and maintainable.
useState vs useReducer
For complex state logic, consider using useReducer
instead of useState
:
// With useState (becomes complex with multiple fields)
function FormWithUseState() {
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Many handler functions for each field...
}
// With useReducer (more organized)
function FormWithUseReducer() {
const initialState = {
username: "",
email: "",
password: "",
errors: {},
isSubmitting: false,
};
const [state, dispatch] = useReducer((state, action) => {
switch (action.type) {
case "FIELD_CHANGE":
return {
...state,
[action.field]: action.value,
};
case "SUBMIT_START":
return {
...state,
isSubmitting: true,
};
case "SUBMIT_SUCCESS":
return {
...initialState,
};
case "SUBMIT_ERROR":
return {
...state,
errors: action.errors,
isSubmitting: false,
};
default:
return state;
}
}, initialState);
// Centralized state management...
}
useReducer
is generally preferable when:
- States are interdependent
- Update logic is complex
- You have many different actions that modify the state
Performance optimization
State updates trigger component re-renders, which can impact performance. React offers several optimization techniques that you can use in a targeted way.
React.memo
React.memo
is a HOC (Higher-Order Component) that allows memorizing a component and preventing it from re-rendering if its props haven’t changed.
const MemoizedComponent = React.memo(function MyComponent({ name, value }) {
console.log("Component render");
return (
<div>
{name}: {value}
</div>
);
});
This technique is useful for child components that receive the same props across multiple consecutive renders and whose rendering is expensive.
useCallback
The useCallback
hook allows memorizing a function between renders. This is particularly useful for functions passed as props to memoized components.
function Parent() {
// Without useCallback, this function would be recreated on each render
const handleClick = useCallback(() => {
console.log("Button clicked");
}, []); // Empty dependency array = stable function
return <MemoizedButton onClick={handleClick} />;
}
Without useCallback
, each parent component render would create a new function with a different reference, which would cause a re-render of the memoized child component.
useMemo
useMemo
memorizes the result of a calculation between renders. It’s useful to avoid recalculating expensive values on each render.
function ExpensiveComponent({ data }) {
// The expensive processing is only performed if data changes
const processedData = useMemo(() => {
return data.map(item => /* complex processing */);
}, [data]);
return <div>{processedData.length} processed items</div>;
}
This optimization is particularly relevant for calculations, data transformations, or creating complex objects.
Note on React 19 and the React compiler
With the introduction of the React compiler in React 19, some of these manual optimizations are less necessary than before. The compiler can automatically detect and optimize many cases where React.memo
, useMemo
, and useCallback
would have been necessary in previous versions.
However, these APIs remain useful in complex cases where the compiler cannot automatically optimize, especially:
- For very performance-intensive components
- When props are functions or objects created during rendering
- For third-party libraries that rely on reference equality
As a general rule, start without these optimizations and add them only when you identify a specific performance issue.
Conclusion
The useState
hook is one of the most fundamental tools in React, allowing functional components to maintain and manage their own state. Its apparent simplicity hides important subtleties, particularly its asynchronous behavior and its impact on the rendering cycle of components.
To master useState
, you need to understand:
- Its asynchronous behavior and the importance of functional updates
- Immutability when handling complex objects
- Pitfalls related to closures and update batching
- When to separate state into multiple variables vs using a single object
- How to optimize performance with memoization
A good understanding of useState
provides a solid foundation for moving on to more advanced hooks like useReducer
, useContext
, or creating your own custom hooks.