Synchronizing with Effect
Synchronizing with Effects – React
Some components may need to synchronize with external systems. For example, controlling server connections or sending analytics logs that are unrelated to React's state after the component appears on the screen.
Effects can synchronize components with external systems outside of React after rendering.
What are Effects? How do they differ from events?
Before learning about Effects, you need to understand two types of logic within React components.
- Rendering code must be pure functions.
- Event handlers are nested functions in components that do something rather than just calculate. They can update fields, send HTTP requests, or navigate to different screens. In other words, event handlers include side effects caused by user actions.
The two types of logic above may not be sufficient. For example, consider a ChatRoom
component that must connect to a chat server when it appears on the screen. The connection to the server is not a pure function (it's a side effect), so it cannot happen during rendering.
Effects allow you to cause side effects by rendering itself, not by specific events. Sending a message in a chat is an event because it happens when a user clicks a specific button. However, server connection is an Effect because it must happen the moment the component appears, regardless of interaction. Effects are executed after the screen is updated, at the end of the commit. This is a good time to synchronize React components with external systems.
Note
The "Effect" so far represents a specialized definition in React, soon meaning a side effect caused by rendering. When referring to the concept in more general programming, I'll use the term "side effect".
You may not need Effect
Don't blindly add Effects to components. Remember that Effects are primarily used to synchronize with external systems outside of React code. External systems here refer to browser APIs, third-party widgets, networks, etc. If the Effect you want to do is simply based on another state, you may not need an Effect.
How to write an Effect
Follow these 3 steps to write an Effect.
- Declare the Effect. By default, Effects run after every commit.
- Specify Effect dependencies. Most Effects should run again only when needed, not after every render.
- Add cleanup function if necessary. Some Effects need to specify how to stop, revert, and clean up when re-rendering. For example, if you connect, you need to disconnect, and if you subscribe, you need to unsubscribe.
Step 1: Define the Effect
To define an Effect in a component, use the useEffect
Hook.
Call it at the top level of your component and put code inside it.
function MyComponent() {
useEffect(() => {
// Code here will run after *every* render
});
return <div />;
}
Every time the component renders, React updates the screen and then executes the code inside useEffect
. In other words, useEffect
delays the execution of code until the rendering is reflected on the screen.
Let's take the VideoPlayer
component as an example.
function VideoPlayer({ src, isPlaying }) {
// TODO: do something with isPlaying
return <video src={src} />;
}
Since the browser's <video>
tag doesn't have properties like isPlaying
, the way to control this is to manually execute the play()
or pause()
methods on the DOM element. In this component, you need to synchronize the value of the isPlaying prop, which indicates whether the video is currently playing, with calls to play()
and pause()
.
You might try to get a ref and call it during rendering, but this is not the right approach.
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
if (isPlaying) {
ref.current.play(); // Calling these while rendering isn't allowed.
} else {
ref.current.pause(); // Also, this crashes.
}
return <video ref={ref} src={src} loop playsInline />;
}
The reason this code is incorrect is that it's trying to do something with the DOM node during rendering. In React, rendering should be a pure function, and side effects like DOM manipulation should not occur.
Moreover, when VideoPlayer
is called for the first time, the DOM doesn't exist yet. There's no node to execute play()
or pause()
on.
The solution is to wrap these side effects with useEffect
to separate them from the rendering calculation.
import { useEffect, useRef } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => { // +
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}); // +
return <video ref={ref} src={src} loop playsInline />;
}
By wrapping the DOM updates in an Effect, you allow React to update the screen first, then the Effect runs.
Warning
By default, Effects run after every render. This is why the following code will create an infinite loop.
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
When the state changes, the component re-renders, the Effect runs again, and as a result, the state changes again, and so on.
Step 2: Specify Effect dependencies
By default, Effects run after every render. But this is often not what you want.
- Synchronization with external systems isn't always immediate, so you might want to skip the operation when it's not necessary. For example, you wouldn't want to reconnect to the server with every keystroke.
- You wouldn't want the component to trigger a fade-in animation with every keystroke. You want such animations to run only once when the component first appears.
useEffect(() => {
if (isPlaying) { // It's used here...
// ...
} else {
// ...
}
}, [isPlaying]); // ...so it must be declared here!
Specifying [isPlaying]
as the dependency array tells React that it should skip re-running the Effect if isPlaying
is the same as during the previous render.
The dependency array can include multiple dependencies. React will only skip re-running the Effect if all the dependency values you specify are exactly the same as during the previous render. React compares the dependency values using the Object.is
comparison.
Remember that you can't choose dependencies. If the dependencies you specify don't match what React expects based on the code inside the Effect, you'll get a lint error. If you don't want to re-run the code, modify the inside of the Effect to "not need the dependency."
-
⚠️ DEEP DIVE Why can refs be omitted from the dependency array?
In the code below, both
ref
andisPlaying
are used inside the Effect, but onlyisPlaying
is specified in the dependencies.
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]);
This is because ref
has a stable identity. React guarantees that you'll always get the same object from the same useRef
call. Therefore, it doesn't matter whether you include ref
in the dependency array or not. Similarly, the set
functions returned by useState
also have stable identities.
Step 3: Add cleanup when needed
Let's say you're writing a ChatRoom
component that needs to connect to a chat server when it appears. You're given an API createConnection()
that returns an object with connect()
and disconnect()
methods. How will you keep the connection active while the component is displayed to the user?
useEffect(() => {
const connection = createConnection();
connection.connect(); // ✅ Connecting...
}, []);
The code inside the Effect doesn't use any props or state, so it has an empty dependency array ([]
). This means the Effect will only run when the component first appears on the screen.
Since this Effect only runs on mount, you might expect "✅ Connecting..." to be logged once. But if you check the console, you'll see it logged twice.
Imagine a ChatRoom
component on a page, and you navigate to another page and then back. The connect()
would be called again, setting up a second connection, but the first connection was never closed.
Such bugs are easy to miss without manual testing, which is why React remounts every component once in development mode after the initial mount.
To fix this issue, return a cleanup function from the Effect.
useEffect(() => {
const connection = createConnection();
connection.connect(); // ✅ Connecting...
return () => {
connection.disconnect(); // ❌ Disconnected.
};
}, []);
React calls the cleanup function before the Effect runs again and when the component is removed.
In the development environment, you'll see three logs:
"✅ Connecting..."
"❌ Disconnected."
"✅ Connecting..."
This is the correct behavior in development mode. By remounting the component, React verifies that the code doesn't break when navigating away and back. Disconnecting and connecting is exactly what should happen! When React remounts your components in development, it's checking your code for bugs, and this is normal behavior, so don't try to eliminate it.
In the production environment, only "✅ Connecting..."
will be output once.
Handling Effects running twice in development
As shown in the example above, React remounts components in development to find bugs. The right question isn't "how to run an Effect once" but "how to fix the Effect to work correctly after remounting."
The general answer is to implement the cleanup function. The cleanup function should stop or undo whatever the Effect was doing. The basic principle is that the user shouldn't be able to distinguish between an effect that runs once (as in production) and an effect that runs, cleans up, and runs again (as in development).
Most Effects you'll write will fall into one of these common patterns:
Controlling non-React widgets
Sometimes you need to add UI widgets that aren't written in React. For example, let's say you're adding a map component. It has a setZoomLevel()
method, and you want to synchronize it with the zoomLevel
state. Your Effect would look something like this:
useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);
In this case, no cleanup is needed. In development mode, React will call the Effect twice, but this isn't a problem because calling setZomLevel()
twice with the same value doesn't do anything. Some APIs might not be idempotent, meaning calling them twice with the same value might produce different results than calling them once. For example, the showModal
method of the <dialog>
element throws an exception when called twice. In this case, implement a cleanup function to close the dialog.
useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);
Subscribing to events
If your Effect subscribes to something, the cleanup function should unsubscribe.
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
Triggering animations
If your Effect animates something in, the cleanup function should reset the animation to its initial values.
useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // Trigger the animation
return () => {
node.style.opacity = 0; // Reset to the initial value
};
}, []);
Fetching data
If your Effect fetches data, the cleanup function should either abort the fetch or ignore its result.
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
ignore = true;
};
}, [userId]);
You can't "cancel" a network request that already happened, but your cleanup function should ensure that the fetch that's no longer relevant doesn't affect the state.
In development, you'll see two fetches in the network tab. With the approach above, the first Effect is immediately cleaned up, so the copy of the ignore
variable is set to true
. So even though there's an extra request, it won't affect the state thanks to the if
check.
In production, there will be only one request. If the second request in development bothers you, the best approach would be to use a solution that deduplicates requests and caches responses between components:
function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...
What are good alternatives to fetching data in Effects?
Calling fetch
directly in Effects is a popular way to fetch data, especially in fully client-side apps. However, this is a very manual approach with significant drawbacks:
- Effects don't run on the server. This means the initial server-rendered HTML will only include a loading state with no data. The client computer will have to download all JavaScript and render your app just to discover that it now needs to load the data.
- Fetching directly in Effects makes it easy to create "network waterfalls". You render the parent component, it fetches some data, renders the child components, and then they start fetching their data. If the network is not very fast, this is significantly slower than fetching all data in parallel.
- Fetching directly in Effects means you don't preload or cache data. For example, if the component unmounts and then mounts again, it would have to fetch the data again.
- It's not very ergonomic. There's quite a bit of boilerplate code involved to write a data fetching implementation that doesn't suffer from bugs.
This list of drawbacks is not specific to React. It applies to fetching data on mount with any library. Like with routing, data fetching is not trivial to do well, so we recommend these approaches:
- If you use a framework, use its built-in data fetching mechanism. Modern React frameworks have integrated data fetching mechanisms that are efficient and don't suffer from the above pitfalls.
- Otherwise, consider using or building a client-side cache. Popular open-source solutions include React Query, useSWR, and React Router 6.4+.
Sending analytics
Consider code that sends a page visit analytics event when the page is visited.
useEffect(() => {
logVisit(url); // Sends a POST request
}, [url]);
In development, logVisit
will be called twice for each URL, so you might want to fix that. However, we recommend keeping it as is. Like with the previous examples, there is no user-visible difference between running once or twice. From a practical standpoint, logVisit
shouldn't do anything in development because you don't want development data to skew the production metrics.
In the production environment, there will be no duplicate visit logs.
Not an Effect: Application initialization
Some logic should run only once when the application starts. You can put such logic outside your components.
if (typeof window !== 'undefined') { // Check if running in the browser.
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
This ensures the logic runs only once after the page loads.