Synchronizing with Effect
Synchronizing with Effects - React
Some components may need to synchronize with external systems. For example, connecting to servers, sending analytics logs, or controlling things that are independent of React's state after the component is on the screen.
Something calledEffects can help you synchronize your components with systems outside of React after rendering.
What are Effects and how are they different from Events?
Before we talk about Effects, it's important to understand the two types of logic within React components.
- Rendering code should be pure functions.
- Event handlers are nested functions in a component that do something other than just compute. It might update a field, send an HTTP request, or navigate to another screen. In other words, event handlers contain side effects that result from user actions.
The above two pieces of logic might not be enough. For example, consider a chat room
component that must be unconditionally connected to a chat server when it is displayed on the screen. The connection to the server is not a pure function (it's a side effect), so it can't happen during rendering.
Effectsallow side effects to be caused by the rendering itself, rather than by a specific event. Sending a message in chat is an event. This is because it happens when the user clicks a specific button. However, connecting to the server is an Effect because it should happen the moment the component appears, regardless of the interaction. Effects run at the end of a commit, after the screen updates. This is a good time to synchronize your React component with external systems.
π‘ Note Up to this point, "Effect" has a React-specific definition, meaning a secondary effect caused by rendering. When referring to the concept in more general programming, we'll use "side effects".
You may not need an Effect.
Don't add Effects to your components willy-nilly. Remember that Effects are primarily used to get out of React code and synchronize with external systems. By external systems, we mean browser APIs, third-party widgets, networks, etc. If the effect you want to do is simply based on a different state, you may not need an Effect.
How to Write an Effect
To write an Effect, follow the three steps below.
- Declare the Effect. By default, Effects are executed after every commit.
- Specify Effect dependencies. Most Effects should only run again when needed, not after every render.
- Add cleanup functions if needed. Some Effects need to specify how to stop, revert, and clean up on re-render. For example, if you're connected, you need to disconnect, or if you're subscribed, you need to unsubscribe.
Step 1: Define the Effect
To define an Effect in your Component, you use the useEffect
Hook.
Call it at the top level of your component, and put the following code inside
function MyComponent() {
useEffect(() => {
// Code here will run after *every* render
});
return <div />;
}
Each time the component renders, React updates the screen and executes the code in the
useEffect
. In other words, the useEffect
delays the execution of the code until the render is reflected on the screen.
Let's use theVideoPlayer
component as an example.
function VideoPlayer({ src, isPlaying }) {
// TODO: do something with isPlaying
return <video src={src} />;
}
Since the <video>
tag in the browser doesn't have an attribute like isPlaying
, the only way to control it is to manually fire the play()
or pause(
) method on the DOM element. In this component, we need to synchronize the value of the isPlaying prop, which tells us whether the video is currently playing, with calls like play()
and pause()
.
You could try to do this by getting a ref and calling it during rendering, but this is not the correct 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 because it tries to do something with DOM nodes during rendering. In React, rendering should be a pure function and shouldn't have any side effects like DOM manipulation.
Furthermore, when VideoPlayer
is called for the first time, the DOM doesn't exist yet. There are no nodes to execute play()
or pause()
on.
The solution is to wrap these side effects in a useEffect
to separate them from the rendering operations.
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 />;
}
When you wrap a DOM update in an Effect, React updates the screen first, and then the Effect runs.
β οΈ Caution!
By default, Effects are executed after every render. For this reason, the following code will create an infinite loop
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
When the state changes, the component is re-rendered and the Effect is executed again. This results in another state change, and so on.
Step 2: Specifying Effect Dependencies
By default, Effects are executed after every render. However, this is often not what you want.
- Because synchronizing with external systems doesn't always happen instantly, you may want to skip behavior when it's not needed. For example, you don't want to reconnect to the server after every keystroke.
- You don't want your component to trigger a fade-in animation on every keystroke. You want these 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]
in the dependency array tells React that if isPlaying
was the same as before during the previous render, it shouldn't run the Effect again.
The dependency array can contain multiple dependencies. React will only skip rerunning the Effect if the specified dependency values exactly match the values from the previous render. React compares dependency values using an Object.is
comparison.
Keep in mind that you can't select 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 rerun your code, modify the code inside Effect to make it "dependency-less".
-
β οΈ DEEP DIVE Why is it okay to omit refs from the dependency array?
The code below uses both
ref
andisPlaying
inside of 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 refs
have stable identities. React guarantees that you will always get the same object from the same useRef
call, so it doesn't matter if you include ref
in the dependency array or not. Similarly, the set
functions returned by useState
have stable identities.
Step 3: Add cleanup as needed
Let's say you're writing a ChatRoom
component that needs to connect to a chat server when it appears. You've been given the createConnection()
API, which returns an object with methods connect()
and disconnect()
. How do you keep the connection alive while it is visible 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 ([]
), meaning the Effect will only run the first time the component appears on the screen.
Because this Effect only runs when it is mounted, the console message "β Connecting..." will only be snapped once. will only be captured once. However, when we check the console, we see that it is captured twice.
Imagine if you navigate away from the page with the ChatRoom
component, and then come back to it, you'll call connect()
again, establishing a second connection, but the first one hasn't been closed. These connections will continue to pile up.
Because bugs like this are easy to miss unless you test for them manually, React remounts all components once after the initial mount in development mode.
To fix this problem, have your Effect return a cleanup function
useEffect(() => {
const connection = createConnection();
connection.connect(); // β
Connecting...
return () => {
connection.disconnect(); // β Disconnected.
};
}, []);
React calls the cleanup function each time the Effect is run again, and finally when the component is removed.
Now, in your development environment, you should see the following three logs being taken.
"β Connecting..."
"β Disconnected."
"β Connecting..."
This is the correct behavior in development mode. By remounting the component, we verify that the code doesn't break when React navigates elsewhere and comes back. Disconnecting and connecting is exactly what should happen - it's React inspecting your code and looking for bugs, and it's normal behavior, so don't try to get rid of it!
In a deployment environment, you****should only see "β
Connecting..."
printed once.
How to deal with Effect running twice in your development environment
React will remount your component to find bugs in your development environment, like in the example above. The right question here is not "How do I get Effect to run only once?", but "How do I fix Effect to work after a remount?".
The general answer is to implement a cleanup function. A cleanup function aborts or reverts the work that Effect was doing. The basic principle is that the user shouldn't be able to tell the difference between an Effect that runs once (as in a deployment) and an Effect that is remounted.
Most Effects you'll write will fall into one of the general patterns below.
Controlling widgets that aren't written in React
Sometimes you'll need to add UI widgets that aren't written in React. For example, let's say you want to add a map component. If your map component has a setZomLevel()
method and synchronizes with the zoomLevel
state, your Effect might look like this
useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);
In this case, no cleanup is required. In development mode, React will call setZomLevel()
twice because it calls the Effect twice, but in a deployment environment this is not a problem because it will not be remounted unnecessarily.
Some APIs may not allow you to call them twice in a row. For example, the showModal
method of <dialog>
. It throws an exception if 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 you subscribe to something in the Effect, you need to unsubscribe from it in the cleanup function
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
Animation Triggers
If you animate something in an Effect, you need to set the animation to an initial value in the cleanup function.
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 the Effect fetches any data, the cleanup function should either abort the fetch or ignore the result.
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
ignore = true;
};
}, [userId]);
While we can't "cancel" network requests that have already occurred, we do need to make sure that the cleanup function doesn't affect fetches that are no longer relevant.
In the development environment, you will see two fetches in the Network tab. Using the above approach, the first Effect will be cleaned up immediately, setting a copy of the ignore
variable to true
, so even if there are additional requests, they won't affect the state thanks to the if
check.
In a deployment environment, there will only be one request. If the second request bothers you in your development environment, your best bet is to use a solution that caches responses between components.
function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...
Calling fetch
inside Effect is the most popular way to fetch data, especially in fully client-side apps. However, it's a very passive approach and has a significant drawback.
- Effect doesn't run on a server. It has to download all the JavaScript and render your app before it can load data. This is not efficient.
- Importing directly inside Effect can easily create a "network waterfall". You render the parent component, fetch some data, render the child component again, and the child component starts fetching its own data. This is much slower than fetching in parallel unless your network is fast.
- Fetching directly inside Effect means that it is not preloaded or cached. For example, if a component is remounted, the data must be reimported.
- Not very convenient. It requires a lot of boilerplate code to write in a bug-free way.
Fetching data is a difficult task to do well, so we recommend the following approaches
- If you're using a framework, use its built-in data fetching mechanisms.
- Otherwise, consider adopting an open source solution like React Query, useSWR, or React Router 6.4+.
Sending analytics
Let's look at the code to send analytics on page visits.
useEffect(() => {
logVisit(url); // Sends a POST request
}, [url]);
In a development environment, you might be tempted to modify logVisit
because it's called twice. However, we recommend keeping it as is. As with the previous example, there is no difference in behavior that the user can see between running it once or twice . In fact, in a development environment, logVisit
shouldn't do anything, because you don't want to collect logs in a development environment and skew your product metrics.
In a deployment environment, there will be no duplicate visit logs.
If not an Effect: Application initialization
Some logic needs to be performed once when the application starts. This logic can be placed outside of the component.
if (typeof window !== 'undefined') { // Check if the browser is running.
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
By executing this logic outside of the component as shown above, it is guaranteed to be executed only once after the page loads.