Source: https://react.dev/learn/you-might-not-need-an-effect
Core Principle
Effects are an escape hatch from the React paradigm. They let you "step outside" of React and synchronize your components with external systems like:
- Non-React widgets
- Network
- Browser DOM
If there is no external system involved (for example, if you want to update a component's state when some props or state change), you shouldn't need an Effect.
Removing unnecessary Effects will make your code:
- β Easier to follow
- β Faster to run
- β Less error-prone
When You DON'T Need Effects
1. Transforming Data for Rendering
β Bad: Using Effect to update state
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// π΄ Avoid: redundant state and unnecessary Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
}
Problem: This does an entire render pass with a stale value, then immediately re-renders with the updated value.
β Good: Calculate during rendering
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// β
Good: calculated during rendering
const fullName = firstName + ' ' + lastName;
}
When something can be calculated from existing props or state, don't put it in state. Calculate it during rendering instead.
2. Caching Expensive Calculations
β Bad: Using Effect with state
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// π΄ Avoid: redundant state and unnecessary Effect
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
}
β Good: Use useMemo for expensive calculations
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// β
Does not re-run unless todos or filter change
const visibleTodos = useMemo(() => {
return getFilteredTodos(todos, filter);
}, [todos, filter]);
}
Or even simpler if the calculation is not expensive:
const visibleTodos = getFilteredTodos(todos, filter);
The function you wrap in useMemo runs during rendering, so this only works for pure calculations.
3. Resetting All State When a Prop Changes
β Bad: Using Effect to reset state
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// π΄ Avoid: Resetting state on prop change in an Effect
useEffect(() => {
setComment('');
}, [userId]);
}
Problem: This is inefficient because ProfilePage and its children will first render with the stale value, and then render again.
β Good: Use a key to reset state
export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId} // β
This and any other state below will reset on key change automatically
/>
);
}
function Profile({ userId }) {
const [comment, setComment] = useState('');
// ...
}
By passing userId as a key, you're asking React to treat two Profile components with different userId as two different components that should not share any state.
4. Adjusting Some State When a Prop Changes
β Bad: Using Effect to sync derived state
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// π΄ Avoid: Adjusting state on prop change in an Effect
useEffect(() => {
setSelection(null);
}, [items]);
}
β Better: Adjust state during rendering
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// β
Best: Calculate everything during rendering
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
}
β Best: Calculate everything during rendering
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// β
Best: Calculate everything during rendering
const selection = items.find(item => item.id === selectedId) ?? null;
}
Storing information from previous renders can be hard to understand, but it's better than updating the same state in an Effect. Always check whether you can reset all state with a key or calculate everything during rendering instead.
5. Sharing Logic Between Event Handlers
β Bad: Using Effect for event-specific logic
function ProductPage({ product, addToCart }) {
// π΄ Avoid: Event-specific logic inside an Effect
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the shopping cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
}
Problem: This Effect is unnecessary and will cause bugs. The notification will appear every time the page reloads if the product is already in the cart.
β Good: Extract shared logic into a function
function ProductPage({ product, addToCart }) {
// β
Good: Event-specific logic is called from event handlers
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to the shopping cart!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
}
Key Question: Ask yourself whether some code should be in an Effect or in an event handler:
- Event Handler: If this logic is caused by a particular interaction
- Effect: If it's caused by the user seeing the component on the screen
6. Sending POST Requests
β Bad: Form submission in Effect
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// π΄ Avoid: Event-specific logic inside an Effect
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);
function handleSubmit(e) {
e.preventDefault();
setJsonToSubmit({ firstName, lastName });
}
}
β Good: Analytics in Effect, form submission in event handler
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// β
Good: This logic should run because the component was displayed
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
function handleSubmit(e) {
e.preventDefault();
// β
Good: Event-specific logic is in the event handler
post('/api/register', { firstName, lastName });
}
}
Use Effects only for code that should run because the component was displayed to the user.
7. Chains of Computations
β Bad: Chaining Effects
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// π΄ Avoid: Chains of Effects that adjust the state solely to trigger each other
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1);
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
useEffect(() => {
alert('Good game!');
}, [isGameOver]);
}
Problems:
- Very inefficient: component re-renders between each
setcall - Rigid and fragile: hard to evolve as requirements change
β Good: Calculate during rendering, update in event handler
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
// β
Calculate what you can during rendering
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
}
// β
Calculate all the next state in the event handler
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
}
}
This is a lot more efficient. Also, if you implement a way to view game history, you can set each state variable to a move from the past without triggering the Effect chain.
8. Initializing the Application
β Bad: App initialization in Effect
function App() {
// π΄ Avoid: Effects with logic that should only ever run once
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
}
Problem: This will run twice in development, which can cause issues (e.g., invalidating authentication token).
β Good: Top-level initialization
if (typeof window !== 'undefined') { // Check if we're running in the browser
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
β Alternative: Use a flag
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// β
Only runs once per app load
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
}
Code at the top level runs once when your component is importedβeven if it doesn't end up being rendered.
9. Notifying Parent Components About State Changes
β Bad: Using Effect to call parent's onChange
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
// π΄ Avoid: The onChange handler runs too late
useEffect(() => {
onChange(isOn);
}, [isOn, onChange]);
function handleClick() {
setIsOn(!isOn);
}
}
Problem: The Toggle updates its state first, and React updates the screen. Then React runs the Effect, which calls the onChange function passed from a parent component. Now the parent component will update its own state, starting another render pass.
β Good: Update both in the same event handler
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateToggle(nextIsOn) {
// β
Good: Perform all updates during the event that caused them
setIsOn(nextIsOn);
onChange(nextIsOn);
}
function handleClick() {
updateToggle(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
updateToggle(true);
} else {
updateToggle(false);
}
}
}
With this approach, both the Toggle component and its parent component update their state during the event. React batches updates from different components together, so there will only be one render pass.
β Even Better: Lift state up (fully controlled component)
// β
Also good: the component is fully controlled by its parent
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
onChange(true);
} else {
onChange(false);
}
}
}
"Lifting state up" lets the parent component fully control the Toggle by toggling the parent's own state.
10. Passing Data to the Parent
β Bad: Fetching in child and passing to parent
function Parent() {
const [data, setData] = useState(null);
// ...
return <Child onFetched={setData} />;
}
function Child({ onFetched }) {
const data = useSomeAPI();
// π΄ Avoid: Passing data to the parent in an Effect
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
}
Problem: In React, data flows from the parent components to their children. When you see something wrong on the screen, you can trace where the information comes from by going up the component chain.
β Good: Fetch in parent and pass down
function Parent() {
const data = useSomeAPI();
// ...
// β
Good: Passing data down to the child
return <Child data={data} />;
}
function Child({ data }) {
// ...
}
This is simpler and keeps the data flow predictable: the data flows down from the parent to the child.
When You DO Need Effects
1. Subscribing to External Stores
β οΈ Manual subscription (not ideal)
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}
updateState();
window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}
β Better: Use useSyncExternalStore
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
// β
Good: Subscribing to an external store with a built-in Hook
return useSyncExternalStore(
subscribe, // React won't resubscribe for as long as you pass the same function
() => navigator.onLine, // How to get the value on the client
() => true // How to get the value on the server
);
}
This approach is less error-prone than manually syncing mutable data to React state with an Effect.
2. Fetching Data
β Fetching in Effect (with proper cleanup)
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true; // β
Cleanup function to ignore stale responses
};
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
}
Why this is appropriate:
- You want to keep
resultssynchronized with the currentqueryandpage - The fetch happens because the component was displayed, not because of a specific interaction
Important: The cleanup function with ignore flag prevents race conditions when typing fast.
β Even Better: Use a framework or custom hook
function SearchResults({ query }) {
const [page, setPage] = useState(1);
const params = new URLSearchParams({ query, page });
// β
Modern frameworks provide more efficient built-in data fetching
const results = useData(`/api/search?${params}`);
function handleNextPageClick() {
setPage(page + 1);
}
}
Modern frameworks provide more efficient built-in data fetching mechanisms that handle:
- Caching responses
- Avoiding network waterfalls
- Prefetching data
- Server-side rendering
Key Takeaways
Decision Tree: Effect vs Event Handler
Ask yourself: What kind of logic is this from the user's perspective?
- Caused by a particular interaction β Event Handler
- Caused by the user seeing the component β Effect
State Behaves Like a Snapshot
function handleClick() {
setRound(round + 1); // round is still the old value here
// If you need the next value:
const nextRound = round + 1;
// Use nextRound for calculations
}
Inside event handlers, state reflects the value at the time the user clicked.
Benefits of Avoiding Unnecessary Effects
- Faster - Avoids extra render passes and cascading updates
- Simpler - Less code, easier to understand data flow
- Less error-prone - Avoids bugs from synchronization issues
- More maintainable - Easier to modify and extend
When Effects ARE Appropriate
- Synchronizing with external systems (browser APIs, third-party libraries, network)
- Data fetching (with proper cleanup for race conditions)
- Subscribing to external stores (prefer
useSyncExternalStore)
Common Patterns Summary
| Scenario | β Don't Use Effect | β Use Instead |
|---|---|---|
| Transform data | Effect + setState | Calculate during render |
| Cache expensive calc | Effect + setState | useMemo |
| Reset all state | Effect on prop change |
key prop |
| Adjust some state | Effect on prop change | Calculate during render |
| Share event logic | Effect | Extracted function |
| POST on user action | Effect | Event handler |
| POST on page view | Event handler | Effect |
| Chain of updates | Multiple Effects | Single event handler |
| App initialization | Effect | Top-level code |
| Notify parent | Effect calling onChange | Call in event handler |
| Pass data to parent | Effect | Lift state up |
| Subscribe to store | Manual Effect | useSyncExternalStore |
| Fetch data | Manual Effect | Framework/custom hook |
Remember: Effects are a powerful feature, but they're also an escape hatch. Use them when you need to synchronize with systems outside of React, not for orchestrating your application's data flow.
Top comments (0)