From 5cea9c4f5620cf124bba4ff50ff53db0ab47c275 Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 5 Aug 2022 19:51:57 +0100 Subject: [PATCH] [Beta] Lifecycle of Reactive Effects (#4875) * [Beta] Lifecycle of Reactive Effects * rm future pages * more --- .../learn/lifecycle-of-reactive-effects.md | 2103 +++++++++++++++++ .../pages/learn/synchronizing-with-effects.md | 22 +- .../pages/learn/updating-objects-in-state.md | 4 +- .../learn/you-might-not-need-an-effect.md | 100 +- beta/src/sidebarLearn.json | 4 + 5 files changed, 2216 insertions(+), 17 deletions(-) create mode 100644 beta/src/pages/learn/lifecycle-of-reactive-effects.md diff --git a/beta/src/pages/learn/lifecycle-of-reactive-effects.md b/beta/src/pages/learn/lifecycle-of-reactive-effects.md new file mode 100644 index 00000000..cdfcbc1a --- /dev/null +++ b/beta/src/pages/learn/lifecycle-of-reactive-effects.md @@ -0,0 +1,2103 @@ +--- +title: 'Lifecycle of Reactive Effects' +--- + + + +Effects have a different lifecycle from components. Components may mount, update, or unmount. An Effect can only do two things: to start synchronizing something, and later to stop synchronizing it. This cycle can happen multiple times if your Effect depends on props and state that change over time. React provides a linter rule to check that you've specified your Effect's dependencies correctly. This keeps your Effect synchronized to the latest props and state. + + + + + +- How an Effect's lifecycle is different from a component's lifecycle +- How to think about each individual Effect in isolation +- When your Effect needs to re-synchronize, and why +- How your Effect's dependencies are determined +- What it means for a value to be reactive +- What an empty dependency array means +- How React verifies your dependencies are correct with a linter +- What to do when you disagree with the linter + + + +## The lifecycle of an Effect {/*the-lifecycle-of-an-effect*/} + +Every React component goes through the same lifecycle: + +- A component _mounts_ when it's added to the screen. +- A component _updates_ when it receives new props or state. This usually happens in response to an interaction. +- A component _unmounts_ when it's removed from the screen. + +**It's a good way to think about components, but _not_ about Effects.** Instead, try to think about each Effect independently from your component's lifecycle. An Effect describes how to [synchronize an external system](/learn/synchronizing-with-effects) to the current props and state. As your code changes, this synchronization will need to happen more or less often. + +To illustrate this point, consider this Effect connecting your component to a chat server: + +```js +const serverUrl = 'https://localhost:1234'; + +function ChatRoom({ roomId }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId]); + // ... +} +``` + +Your Effect's body specifies how to **start synchronizing:** + +```js {2-3} + // ... + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + // ... +``` + +The cleanup function returned by your Effect specifies how to **stop synchronizing:** + +```js {5} + // ... + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + // ... +``` + +Intuitively, you might think that React would **start synchronizing** when your component mounts and **stop synchronizing** when your component unmounts. However, this is not the end of the story! Sometimes, it may also be necessary to **start and stop synchronizing multiple times** while the component remains mounted. + +Let's look at _why_ this is necessary, _when_ it happens, and _how_ you can control this behavior. + + + +Some Effects don't return a cleanup function at all. [More often than not,](/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) you'll want to return one--but if you don't, React will behave as if you returned an empty cleanup function that doesn't do anything. + + + +### Why synchronization may need to happen more than once {/*why-synchronization-may-need-to-happen-more-than-once*/} + +Imagine this `ChatRoom` component receives a `roomId` prop that the user picks in a dropdown. Let's say that initially the user picks the `"general"` room as the `roomId`. Your app displays the `"general"` chat room: + +```js {3} +const serverUrl = 'https://localhost:1234'; + +function ChatRoom({ roomId /* "general" */ }) { + // ... + return

Welcome to the {roomId} room!

; +} +``` + +After the UI is displayed, React will run your Effect to **start synchronizing**. It connects to the `"general"` room: + +```js {3,4} +function ChatRoom({ roomId /* "general" */ }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); // Connects to the "general" room + connection.connect(); + return () => { + connection.disconnect(); // Disconnects from the "general" room + }; + }, [roomId]); + // ... +``` + +So far, so good. + +Later, the user picks a different room in the dropdown (for example, `"travel"`). First, React will update the UI: + +```js {1} +function ChatRoom({ roomId /* "travel" */ }) { + // ... + return

Welcome to the {roomId} room!

; +} +``` + +Pause to think about what should happen next. The user sees that `"travel"` is the selected chat room in the UI. However, the Effect that ran the last time is still connected to the `"general"` room. **The `roomId` prop has changed, so whatever your Effect did back then (connecting to the `"general"` room) no longer matches the UI.** + +At this point, you want React to do two things: + +1. Stop synchronizing with the old `roomId` (disconnect from the `"general"` room) +2. Start synchronizing with the new `roomId` (connect to the `"travel"` room) + +**Luckily, you've already taught React how to do both of these things!** Your Effect's body specifies how to start synchronizing, and your cleanup function specifies how to stop synchronizing. All that React needs to do now is to call them in the correct order and with the correct props and state. Let's see how exactly that happens. + +### How React re-synchronizes your Effect {/*how-react-re-synchronizes-your-effect*/} + +Recall that your `ChatRoom` component has received a new value for its `roomId` prop. It used to be `"general"`, and now it is `"travel"`. React needs to re-synchronize your Effect to re-connect you to a different room. + +To **stop synchronizing,** React will call the cleanup function that your Effect returned after connecting to the `"general"` room. Since `roomId` was `"general"`, the cleanup function disconnects from the `"general"` room: + +```js {6} +function ChatRoom({ roomId /* "general" */ }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); // Connects to the "general" room + connection.connect(); + return () => { + connection.disconnect(); // Disconnects from the "general" room + }; + // ... +``` + +Then React will run the Effect that you've provided during this render. This time, `roomId` is `"travel"` so it will **start synchronizing** to the `"travel"` chat room (until its cleanup function is eventually called too): + +```js {3,4} +function ChatRoom({ roomId /* "travel" */ }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); // Connects to the "travel" room + connection.connect(); + // ... +``` + +Thanks to this, you're now connected to the same room that the user chose in the UI. Disaster averted! + +Every time after your component re-renders with a different `roomId`, your Effect will re-synchronize. For example, let's say the user changes `roomId` from `"travel"` to `"music"`. React will again **stop synchronizing** your Effect by calling its cleanup function (disconnecting you from the `"travel"` room). Then it will **start synchronizing** again by running its body with the new `roomId` prop (connecting you to the `"music` room). + +Finally, when the user goes to a different screen, `ChatRoom` unmounts. Now there is no need to stay connected at all. React will **stop synchronizing** your Effect one last time and disconnect you from the `"music"` chat room. + +### Thinking from the Effect's perspective {/*thinking-from-the-effects-perspective*/} + +Let's recap everything that's happened from the `ChatRoom` component's perspective: + +1. `ChatRoom` mounted with `roomId` set to `"general"` +1. `ChatRoom` updated with `roomId` set to `"travel"` +1. `ChatRoom` updated with `roomId` set to `"music"` +1. `ChatRoom` unmounted + +During each of these points in the component's lifecycle, your Effect did different things: + +1. Your Effect connected to the `"general"` room +1. Your Effect disconnected from the `"general"` room and connected to the `"travel"` room +1. Your Effect disconnected from the `"travel"` room and connected to the `"music"` room +1. Your Effect disconnected from the `"music"` room + +Now let's think about what happened from the perspective of the Effect itself: + +```js + useEffect(() => { + // Your Effect connected to the room specified with roomId... + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + // ...until it disconnected + connection.disconnect(); + }; + }, [roomId]); +``` + +This code's structure might inspire you to see what happened as a sequence of non-overlapping time periods: + +1. Your Effect connected to the `"general"` room (until it disconnected) +1. Your Effect connected to the `"travel"` room (until it disconnected) +1. Your Effect connected to the `"music"` room (until it disconnected) + +Previously, you were thinking from the component's perspective. When you looked from the component's perspective, it was tempting to think of Effects as "callbacks" or "lifecycle events" that fire at a specific time like "after a render" or "before unmount". This way of thinking gets complicated very fast, so it's best to avoid it. + +**Instead, always focus on a single start/stop cycle at a time. It shouldn't matter whether a component is mounting, updating, or unmounting. All you need to do is to describe how to start synchronization and how to stop it. If you do it well, your Effect will be resilient to being started and stopped as many times as it's needed.** + +This might remind you how you don't think whether a component is mounting or updating when you write the rendering logic that creates JSX. You describe what should be on the screen, and React [figures out the rest.](/learn/reacting-to-input-with-state) + +### How React verifies that your Effect can re-synchronize {/*how-react-verifies-that-your-effect-can-re-synchronize*/} + +Here is a live example that you can play with. Press "Open chat" to mount the `ChatRoom` component: + + + +```js +import { useState, useEffect } from 'react'; +import { createConnection } from './chat.js'; + +const serverUrl = 'https://localhost:1234'; + +function ChatRoom({ roomId }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + return

Welcome to the {roomId} room!

; +} + +export default function App() { + const [roomId, setRoomId] = useState('general'); + const [show, setShow] = useState(false); + return ( + <> + + + {show &&
} + {show && } + + ); +} +``` + +```js chat.js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...'); + }, + disconnect() { + console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl); + } + }; +} +``` + +```css +input { display: block; margin-bottom: 20px; } +button { margin-left: 10px; } +``` + +
+ +Notice that when the component mounts for the first time, you see three logs: + +1. `✅ Connecting to "general" room at https://localhost:1234...` *(development-only)* +1. `❌ Disconnected from "general" room at https://localhost:1234.` *(development-only)* +1. `✅ Connecting to "general" room at https://localhost:1234...` + +The first two logs are development-only. In development, React always remounts each component once. **In other words, React verifies that your Effect can re-synchronize by forcing it to do that immediately in development.** This might remind you how you might open the door and close it an extra time to check that the door lock works. React starts and stops your Effect one extra time in development to check [you've implemented its cleanup well.](/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) + +The main reason your Effect will re-synchronize in practice is if some data it uses has changed. In the sandbox above, change the selected chat room. Notice how, when the `roomId` changes, your Effect re-synchronizes. + +However, there are also more unusual cases in which re-synchronization is necessary. For example, try editing the `serverUrl` in the sandbox above while the chat is open. Notice how the Effect re-synchronizes in response to your edits to the code. In the future, React may add more features that take advantage of re-synchronization. + +### How React knows that it needs to re-synchronize the Effect {/*how-react-knows-that-it-needs-to-re-synchronize-the-effect*/} + +You might be wondering how React knew that your Effect needed to re-synchronize after `roomId` changes. It's because *you told React* that this Effect's code depends on `roomId` by including it in the [list of dependencies:](/learn/synchronizing-with-effects#step-2-specify-the-effect-dependencies) + +```js {1,3,8} +function ChatRoom({ roomId }) { // The roomId prop may change over time + useEffect(() => { + const connection = createConnection(serverUrl, roomId); // This Effect reads roomId + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId]); // So you tell React that this Effect "depends on" roomId + // ... +``` + +Here's how this works: + +1. You knew `roomId` is a prop, which means it can change over time. +2. You knew that your Effect reads `roomId` (so its logic depends on a value that may change later). +3. This is why you specified it as your Effect's dependency (so that it re-synchronizes when `roomId` changes). + +Every time after your component re-renders, React will look at the array of dependencies that you have passed. If any of the values in the array is different from the value at the same spot that you passed during the previous render, React will re-synchronize your Effect. For example, if you passed `["general"]` during the initial render, and later you passed `["travel"]` during the next render, React will compare `"general"` and `"travel"`. These are different values (compared with [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is)), so React will re-synchronize your Effect. On the other hand, if your component re-renders but `roomId` has not changed, your Effect will remain connected to the same room. + +### Each Effect represents a separate synchronization process {/*each-effect-represents-a-separate-synchronization-process*/} + +Resist adding unrelated logic to your Effect only because this logic needs to run at the same time as an Effect you already wrote. For example, let's say you want to send an analytics event when the user visits the room. You already have an Effect that depends on `roomId`, so you might feel tempted to add the analytics call right there: + +```js {3} +function ChatRoom({ roomId }) { + useEffect(() => { + logVisit(roomId); + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId]); + // ... +} +``` + +But imagine you later add another dependency to this Effect that needs to re-establish the connection. If this Effect re-synchronizes, it will also call `logVisit(roomId)` for the same room, which you did not intend. Logging the visit **is a separate process** from connecting. This is why they should be written as two separate Effects: + +```js {2-4} +function ChatRoom({ roomId }) { + useEffect(() => { + logVisit(roomId); + }, [roomId]); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + // ... + }, [roomId]); + // ... +} +``` + +**Each Effect in your code should represent a separate and independent synchronization process.** + +In the above example, deleting one Effect wouldn’t break the other Effect's logic. This is a good indication that they synchronize different things, and so it made sense to split them up. On the other hand, if you split up a cohesive piece of logic into separate Effects, the code may look "cleaner" but will be [more difficult to maintain.](/learn/you-might-not-need-an-effect#chains-of-computations) This is why you should think whether the processes are same or separate, not whether the code looks cleaner. + +## Effects "react" to reactive values {/*effects-react-to-reactive-values*/} + +Your Effect reads two variables (`serverUrl` and `roomId`), but you only specified `roomId` as a dependency: + +```js {5,10} +const serverUrl = 'https://localhost:1234'; + +function ChatRoom({ roomId }) { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId]); + // ... +} +``` + +Why doesn't `serverUrl` need to be a dependency? + +This is because the `serverUrl` never changes due to a re-render. It's always the same no matter how many times and with which props and state the component re-renders. Since `serverUrl` never changes, it wouldn't make sense to specify it as a dependency. After all, dependencies only do something when they change over time! + +On the other hand, `roomId` may be different on a re-render. **Props, state, and other values declared inside the component are _reactive_ because they're calculated during rendering and participate in the React data flow.** + +If `serverUrl` was a state variable, it would be reactive. Reactive values must be included in dependencies: + +```js {2,5,10} +function ChatRoom({ roomId }) { // Props change over time + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // State may change over time + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); // Your Effect reads props and state + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId, serverUrl]); // So you tell React that this Effect "depends on" on props and state + // ... +} +``` + +By including `serverUrl` as a dependency, you ensure that the Effect re-synchronizes after it changes. + +Try changing the selected chat room or edit the server URL in this sandbox: + + + +```js +import { useState, useEffect } from 'react'; +import { createConnection } from './chat.js'; + +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, serverUrl]); + + return ( + <> + +

Welcome to the {roomId} room!

+ + ); +} + +export default function App() { + const [roomId, setRoomId] = useState('general'); + return ( + <> + +
+ + + ); +} +``` + +```js chat.js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...'); + }, + disconnect() { + console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl); + } + }; +} +``` + +```css +input { display: block; margin-bottom: 20px; } +button { margin-left: 10px; } +``` + +
+ +Whenever you change a reactive value like `roomId` or `serverUrl`, the Effect re-connects to the chat server. + +### What an Effect with empty dependencies means {/*what-an-effect-with-empty-dependencies-means*/} + +What happens if you move both `serverUrl` and `roomId` outside the component? + +```js {1,2} +const serverUrl = 'https://localhost:1234'; +const roomId = 'general'; + +function ChatRoom() { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, []); // ✅ All dependencies declared + // ... +} +``` + +Now your Effect's code does not use *any* reactive values, so its dependencies can be empty (`[]`). + +If you think from the component's perspective, the empty `[]` dependency array means this Effect connects to the chat room only when the component mounts, and disconnects only when the component unmounts. (Keep in mind that React would still [re-synchronize it an extra time](#how-react-verifies-that-your-effect-can-re-synchronize) in development to stress-test your Effect's logic.) + + + + +```js +import { useState, useEffect } from 'react'; +import { createConnection } from './chat.js'; + +const serverUrl = 'https://localhost:1234'; +const roomId = 'general'; + +function ChatRoom() { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, []); + return

Welcome to the {roomId} room!

; +} + +export default function App() { + const [show, setShow] = useState(false); + return ( + <> + + {show &&
} + {show && } + + ); +} +``` + +```js chat.js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...'); + }, + disconnect() { + console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl); + } + }; +} +``` + +```css +input { display: block; margin-bottom: 20px; } +button { margin-left: 10px; } +``` + +
+ +However, if you [think from the Effect's perspective,](#thinking-from-the-effects-perspective) you don't need to think about mounting and unmounting at all. What's important is you've specified what your Effect does to start and stop synchronizing. Today, it has no reactive dependencies. But if you ever want the user to change `roomId` or `serverUrl` over time (and so they'd have to become reactive), your Effect's code won't change. You will only need to add them to the dependencies. + +### All variables declared in the component body are reactive {/*all-variables-declared-in-the-component-body-are-reactive*/} + +Props and state aren't the only reactive values. Values that you calculate from them are also reactive. If the props or state change, your component will re-render, and the values calculated from them will also change. This is why all variables from the component body used by the Effect should also be in the Effect dependency list. + +Let's say that the user can pick a chat server in the dropdown, but they can also configure a default server in settings. Suppose you've already put the settings state in a [context](/learn/scaling-up-with-reducer-and-context) so you read the `settings` from that context. Now you calculate the `serverUrl` based on the selected server from props and the default server from context: + +```js {3,5,10} +function ChatRoom({ roomId, selectedServerUrl }) { // roomId is reactive + const settings = useContext(SettingsContext); // settings is reactive + const serverUrl = selectedServerUrl ?? settings.defaultServerUrl; // serverUrl is reactive + useEffect(() => { + const connection = createConnection(serverUrl, roomId); // Your Effect reads roomId and serverUrl + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [roomId, serverUrl]); // So it needs to re-synchronize when either of them changes! + // ... +} +``` + +In this example, `serverUrl` is not a prop or a state variable. It's a regular variable that you calculate during rendering. But it's calculated during rendering, so it can change due to a re-render. This is why it's reactive. + +**All values inside the component (including props, state, and variables in your component's body) are reactive. Any reactive value can change on a re-render, so you need to include reactive values as Effect's dependencies.** + +In other words, Effects "react" to all values from the component body. + + + +Mutable values (including global variables) aren't reactive. + +**A mutable value like [`location.pathname`](https://developer.mozilla.org/en-US/docs/Web/API/Location/pathname) can't be a dependency.** It's mutable, so it can change at any time completely outside of the React rendering data flow. Changing it wouldn't trigger a re-render of your component. Therefore, even if you specified it in the dependencies, React *wouldn't know* to re-synchronize the Effect when it changes. This also breaks the rules of React because reading mutable data during rendering (which is when you calculate the dependencies) breaks [purity of rendering.](/learn/keeping-components-pure) Instead, you should read and subscribe to an external mutable value with [`useSyncExternalStore`](/learn/you-might-not-need-an-effect#subscribing-to-an-external-store). + +**A mutable value like [`ref.current`](/apis/useref#reference) or things you read from it also can't be a dependency.** The ref object returned by `useRef` itself can be a dependency, but its `current` property is intentionally mutable. It lets you [keep track of something without triggering a re-render.](/learn/referencing-values-with-refs) But since changing it doesn't trigger a re-render, it's not a reactive value, and React won't know to re-run your Effect when it changes. + +As you'll learn below on this page, a linter will check for these issues automatically. + + + +### React verifies that you specified every reactive value as a dependency {/*react-verifies-that-you-specified-every-reactive-value-as-a-dependency*/} + +If your linter is [configured for React,](/learn/editor-setup#linting) it will check that every reactive value used by your Effect's code is declared as its dependency. For example, this is a lint error because both `roomId` and `serverUrl` are reactive: + + + +```js +import { useState, useEffect } from 'react'; +import { createConnection } from './chat.js'; + +function ChatRoom({ roomId }) { // roomId is reactive + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // serverUrl is reactive + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, []); // <-- Something's wrong here! + + return ( + <> + +

Welcome to the {roomId} room!

+ + ); +} + +export default function App() { + const [roomId, setRoomId] = useState('general'); + return ( + <> + +
+ + + ); +} +``` + +```js chat.js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...'); + }, + disconnect() { + console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl); + } + }; +} +``` + +```css +input { display: block; margin-bottom: 20px; } +button { margin-left: 10px; } +``` + +
+ +This may look like a React error, but really React is pointing out a bug in your code. Both `roomId` and `serverUrl` may change over time, but you're forgetting to re-synchronize your Effect when they change. As a result, you will remain connected to the initial `roomId` and `serverUrl` even after the user picks different values in the UI. + +To fix the bug, follow the linter's suggestion to specify `roomId` and `serverUrl` as dependencies of your Effect: + +```js {9} +function ChatRoom({ roomId, serverUrl }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // serverUrl is reactive + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, [serverUrl, roomId]); // ✅ All dependencies declared + // ... +} +``` + +Try this fix in the sandbox above. Verify that the linter error is gone, and that the chat re-connects when needed. + + + +In some cases, React *knows* that a value never changes even though it's declared inside the component. For example, the [`set` function](/apis/usestate#setstate) returned from `useState` and the ref object returned by [`useRef`](/apis/useref) are *stable*--they are guaranteed to not change on a re-render. Stable values aren't reactive, so the linter lets you omit them from the list. However, including them is allowed: they won't change, so it doesn't matter. + + + +### What to do when you don't want to re-synchronize {/*what-to-do-when-you-dont-want-to-re-synchronize*/} + +In the previous example, you've fixed the lint error by listing `roomId` and `serverUrl` as dependencies. + +**However, you could instead "prove" to the linter that these values aren't reactive values,** i.e. that they *can't* change as a result of a re-render. For example, if `serverUrl` and `roomId` don't depend on rendering and always have the same values, you can move them outside the component. Now they don't need to be dependencies: + +```js {1,2,11} +const serverUrl = 'https://localhost:1234'; // serverUrl is not reactive +const roomId = 'general'; // roomId is not reactive + +function ChatRoom() { + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, []); // ✅ All dependencies declared + // ... +} +``` + +You can also move them *inside the Effect.* They aren't calculated during rendering, so they're not reactive: + +```js {3,4,10} +function ChatRoom() { + useEffect(() => { + const serverUrl = 'https://localhost:1234'; // serverUrl is not reactive + const roomId = 'general'; // roomId is not reactive + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => { + connection.disconnect(); + }; + }, []); // ✅ All dependencies declared + // ... +} +``` + +**Effects are reactive blocks of code.** They re-synchronize when the values you read inside of them change. Unlike event handlers, which only run once per interaction, Effects run whenever synchronization is necessary. + +**You can't "choose" your dependencies.** Your dependencies must include every [reactive value](#all-variables-declared-in-the-component-body-are-reactive) you read in the Effect. The linter enforces this. Sometimes this may lead to problems like infinite loops and to your Effect re-synchronizing too often. Don't fix these problems by suppressing the linter! Here's what to try instead: + +* **Check that your Effect represents an independent synchronization process.** If your Effect doesn't synchronize anything, [it might be unnecessary.](/learn/you-might-not-need-an-effect) If it synchronizes several independent things, [split it up.](#each-effect-represents-a-separate-synchronization-process) + +* **If you want to read the latest value of props or state without "reacting" to it and re-synchronizing the Effect,** you can split your Effect into a reactive part (which you'll keep in the Effect) and a non-reactive part (which you'll extract into something called an _Event function_). [Read more about opting out of reactivity with Events.](/learn/opting-out-of-reactivity-with-events) + +* **Avoid relying on objects and functions as dependencies.** If you create objects and functions during rendering and then read them from an Effect, they will be different on every render. This will cause your Effect to re-synchronize every time. [Read more about removing unnecessary dependencies from your Effects.](/learn/removing-effect-dependencies) + + + +The linter is your friend, but its powers are limited. The linter only knows when the dependencies are *wrong*. It doesn't know *the best* way to solve each case. If the linter suggests a dependency, but adding it causes a loop, it doesn't mean the linter should be ignored. It means you need to change the code inside (or outside) the Effect so that that value isn't reactive and doesn't *need* to be a dependency. + +If you have an existing codebase, you might have some Effects that suppress the linter like this: + +```js {3-4} +useEffect(() => { + // ... + // 🔴 Avoid suppressing the linter like this: + // eslint-ignore-next-line react-hooks/exhaustive-dependencies +}, []); +``` + +On the [next](/learn/opting-out-of-reactivity-with-events) [pages](/learn/removing-effect-dependencies), you'll learn how to fix this code without breaking the rules. It's always worth fixing! + + + + + +- Components can mount, update, and unmount. +- Each Effect has a separate lifecycle from the surrounding component. +- Each Effect describes a separate synchronization process that can *start* and *stop*. +- When you write and read Effects, you should think from each individual Effect's perspective (how to start and stop synchronization) rather than from the component's perspective (how it mounts, updates, or unmounts). +- Values declared inside the component body are "reactive." +- Reactive values should re-synchronize the Effect because they can change over time. +- The linter verifies that all reactive values used inside the Effect are specified as dependencies. +- All errors flagged by the linter are legitimate. There's always a way to fix the code that doesn't break the rules. + + + + + +### Fix reconnecting on every keystroke {/*fix-reconnecting-on-every-keystroke*/} + +In this example, the `ChatRoom` component connects to the chat room when the component mounts, disconnects when it unmounts, and reconnects when you select a different chat room. This behavior is correct, so you need to keep it working. + +However, there is a problem. Whenever you type into the message box input at the bottom, `ChatRoom` *also* reconnects to the chat. (You can notice this by clearing the console and typing into the input.) Fix the issue so that this doesn't happen. + + + +You might need to add a dependency array for this Effect. What dependencies should be there? + + + + + +```js +import { useState, useEffect } from 'react'; +import { createConnection } from './chat.js'; + +const serverUrl = 'https://localhost:1234'; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(''); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }); + + return ( + <> +

Welcome to the {roomId} room!

+ setMessage(e.target.value)} + /> + + ); +} + +export default function App() { + const [roomId, setRoomId] = useState('general'); + return ( + <> + +
+ + + ); +} +``` + +```js chat.js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...'); + }, + disconnect() { + console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl); + } + }; +} +``` + +```css +input { display: block; margin-bottom: 20px; } +button { margin-left: 10px; } +``` + +
+ + + +This Effect didn't have a dependency array at all, so it re-synchronized after every re-render. First, add a dependency array. Then, make sure that every reactive value used by the Effect is specified in the array. For example, `roomId` is reactive (because it's a prop), so it should be included in the array. This ensures that when the user selects a different room, the chat reconnects. On the other hand, `serverUrl` is defined outside the component. This is why it doesn't need to be in the array. + + + +```js +import { useState, useEffect } from 'react'; +import { createConnection } from './chat.js'; + +const serverUrl = 'https://localhost:1234'; + +function ChatRoom({ roomId }) { + const [message, setMessage] = useState(''); + + useEffect(() => { + const connection = createConnection(serverUrl, roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + + return ( + <> +

Welcome to the {roomId} room!

+ setMessage(e.target.value)} + /> + + ); +} + +export default function App() { + const [roomId, setRoomId] = useState('general'); + return ( + <> + +
+ + + ); +} +``` + +```js chat.js +export function createConnection(serverUrl, roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...'); + }, + disconnect() { + console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl); + } + }; +} +``` + +```css +input { display: block; margin-bottom: 20px; } +button { margin-left: 10px; } +``` + +
+ +
+ +### Switch synchronization on and off {/*switch-synchronization-on-and-off*/} + +In this example, an Effect subscribes to the window [`pointermove`](https://developer.mozilla.org/en-US/docs/Web/API/Element/pointermove_event) event to move a pink dot on the screen. Try hovering over the preview area (or touching the screen if you're on a mobile device), and see how the pink dot follows your movement. + +There is also a checkbox. Ticking the checkbox toggles the `canMove` state variable, but this state variable is not used anywhere in the code. Your task is to change the code so that when `canMove` is `false` (the checkbox is ticked off), the dot should stop moving. After you toggle the checkbox back on (and set `canMove` to `true`), the box should follow the movement again. In other words, whether the dot can move or not should stay synchronized to whether the checkbox is checked. + + + +You can't declare an Effect conditionally. However, the code inside the Effect can use conditions! + + + + + +```js +import { useState, useEffect } from 'react'; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [canMove, setCanMove] = useState(true); + + useEffect(() => { + function handleMove(e) { + setPosition({ x: e.clientX, y: e.clientY }); + } + window.addEventListener('pointermove', handleMove); + return () => window.removeEventListener('pointermove', handleMove); + }, []); + + return ( + <> + +
+
+ + ); +} +``` + +```css +body { + height: 200px; +} +``` + + + + + +One solution is to wrap the `setPosition` call into an `if (canMove) { ... }` condition: + + + +```js +import { useState, useEffect } from 'react'; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [canMove, setCanMove] = useState(true); + + useEffect(() => { + function handleMove(e) { + if (canMove) { + setPosition({ x: e.clientX, y: e.clientY }); + } + } + window.addEventListener('pointermove', handleMove); + return () => window.removeEventListener('pointermove', handleMove); + }, [canMove]); + + return ( + <> + +
+
+ + ); +} +``` + +```css +body { + height: 200px; +} +``` + + + +Alternatively, you could wrap the *event subscription* logic into an `if (canMove) { ... }` condition: + + + +```js +import { useState, useEffect } from 'react'; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [canMove, setCanMove] = useState(true); + + useEffect(() => { + function handleMove(e) { + setPosition({ x: e.clientX, y: e.clientY }); + } + if (canMove) { + window.addEventListener('pointermove', handleMove); + return () => window.removeEventListener('pointermove', handleMove); + } + }, [canMove]); + + return ( + <> + +
+
+ + ); +} +``` + +```css +body { + height: 200px; +} +``` + + + +In both of these cases, `canMove` is a reactive variable that you read inside the Effect. This is why it must be specified in the list of Effect dependencies. This ensures that the Effect re-synchronizes after every change to its value. + + + +### Investigate a stale value bug {/*investigate-a-stale-value-bug*/} + +In this example, the pink dot should move when the checkbox if on, and should stop moving when the checkbox is off. The logic for this has already been implemented: the `handleMove` event handler checks the `canMove` state variable. + +However, for some reason, the `canMove` state variable inside `handleMove` appears to be "stale": it's always `true`, even after you tick off the checkbox. How is this possible? Find the mistake in the code and fix it. + + + +If you see a linter rule being suppressed, remove the suppression! That's where the mistakes usually are. + + + + + +```js +import { useState, useEffect } from 'react'; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [canMove, setCanMove] = useState(true); + + function handleMove(e) { + if (canMove) { + setPosition({ x: e.clientX, y: e.clientY }); + } + } + + useEffect(() => { + window.addEventListener('pointermove', handleMove); + return () => window.removeEventListener('pointermove', handleMove); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + +
+
+ + ); +} +``` + +```css +body { + height: 200px; +} +``` + + + + + +The problem with the original code was suppressing the dependency linter. If you remove the suppression, you'll see that this Effect depends on the `handleMove` function. This makes sense: `handleMove` is declared inside the component body, which makes it a reactive value. Every reactive value must be specified as a depedency, or it can potentially get stale over time! + +The author of the original code has "lied" to React by saying that the Effect does not depend (`[]`) on any reactive values. This is why React did not re-synchronize the Effect after `canMove` has changed (and `handleMove` with it). Because React did not re-synchronize the Effect, the `handleMove` attached as a listener is the `handleMove` function created during the initial render. During the initial render, `canMove` was `true`, which is why `handleMove` from the initial render will forever see that value. + +**If you never suppress the linter, you will never see problems with stale values.** There are a few different ways to solve this bug, but you should always start by removing the linter suppression. Then change the code to fix the lint error. + +You can change the Effect dependencies to `[handleMove]`, but since it's going to be a newly defined function for every render, you might as well remove dependencies array altogether. Then the Effect *will* re-synchronize after every re-render: + + + +```js +import { useState, useEffect } from 'react'; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [canMove, setCanMove] = useState(true); + + function handleMove(e) { + if (canMove) { + setPosition({ x: e.clientX, y: e.clientY }); + } + } + + useEffect(() => { + window.addEventListener('pointermove', handleMove); + return () => window.removeEventListener('pointermove', handleMove); + }); + + return ( + <> + +
+
+ + ); +} +``` + +```css +body { + height: 200px; +} +``` + + + +This solution works, but it's not ideal. If you put `console.log('Resubscribing')` inside the Effect, you'll notice that it resubscribes after every re-render. Resubscribing is fast, but it would still be nice to avoid doing it so often. + +A better fix would be to move the `handleMove` function *inside* the Effect. Then `handleMove` won't be a reactive value, and so your Effect won't depend on a function. Instead, it will need to depend on `canMove` which your code now reads from inside the Effect. This matches the behavior you wanted, since your Effect will now stay synchronized with the value of `canMove`: + + + +```js +import { useState, useEffect } from 'react'; + +export default function App() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [canMove, setCanMove] = useState(true); + + useEffect(() => { + function handleMove(e) { + if (canMove) { + setPosition({ x: e.clientX, y: e.clientY }); + } + } + + window.addEventListener('pointermove', handleMove); + return () => window.removeEventListener('pointermove', handleMove); + }, [canMove]); + + return ( + <> + +
+
+ + ); +} +``` + +```css +body { + height: 200px; +} +``` + + + +Try adding `console.log('Resubscribing')` inside the Effect body and notice that now it only resubscribes when you toggle the checkbox (`canMove` changes) or edit the code. This makes it better than the previous approach that always resubscribed. + +You'll learn a more general approach to this type of problem in [Opting Out of Reactivity with Events](/learn/opting-out-of-reactivity-with-events). + + + +### Fix a connection switch {/*fix-a-connection-switch*/} + +In this example, the chat service in `chat.js` exposes two different APIs: `createEncryptedConnection` and `createUnencryptedConnection`. The root `App` component lets the user choose whether to use encryption or not, and then passes down the corresponding API method to the child `ChatRoom` component as the `createConnection` prop. + +Notice that initially, the console logs say the connection is not encrypted. Try toggling the checkbox on: nothing will happen. However, if you change the selected room after that, then the chat will reconnect *and* enable encryption (as you'll see from the console messages). This is a bug. Fix the bug so that toggling the checkbox *also* causes the chat to reconnect. + + + +Suppressing the linter is always suspicious. Could this be a bug? + + + + + +```js App.js +import { useState } from 'react'; +import ChatRoom from './ChatRoom.js'; +import { + createEncryptedConnection, + createUnencryptedConnection, +} from './chat.js'; + +export default function App() { + const [roomId, setRoomId] = useState('general'); + const [isEncrypted, setIsEncrypted] = useState(false); + return ( + <> + + +
+ + + ); +} +``` + +```js ChatRoom.js active +import { useState, useEffect } from 'react'; + +export default function ChatRoom({ roomId, createConnection }) { + useEffect(() => { + const connection = createConnection(roomId); + connection.connect(); + return () => connection.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [roomId]); + + return

Welcome to the {roomId} room!

; +} +``` + +```js chat.js +export function createEncryptedConnection(roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ 🔐 Connecting to "' + roomId + '... (encrypted)'); + }, + disconnect() { + console.log('❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)'); + } + }; +} + +export function createUnencryptedConnection(roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ Connecting to "' + roomId + '... (unencrypted)'); + }, + disconnect() { + console.log('❌ Disconnected from "' + roomId + '" room (unencrypted)'); + } + }; +} +``` + +```css +label { display: block; margin-bottom: 10px; } +``` + +
+ + + +If you remove the linter suppression, you will see a lint error. The problem is that `createConnection` is a prop, so it's a reactive value. It can change over time! (And indeed, it should--when the user ticks the checkbox, the parent component passes a different value of the `createConnection` prop.) This is why it should be a dependency. Include it in the list to fix the bug: + + + +```js App.js +import { useState } from 'react'; +import ChatRoom from './ChatRoom.js'; +import { + createEncryptedConnection, + createUnencryptedConnection, +} from './chat.js'; + +export default function App() { + const [roomId, setRoomId] = useState('general'); + const [isEncrypted, setIsEncrypted] = useState(false); + return ( + <> + + +
+ + + ); +} +``` + +```js ChatRoom.js active +import { useState, useEffect } from 'react'; + +export default function ChatRoom({ roomId, createConnection }) { + useEffect(() => { + const connection = createConnection(roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, createConnection]); + + return

Welcome to the {roomId} room!

; +} +``` + +```js chat.js +export function createEncryptedConnection(roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ 🔐 Connecting to "' + roomId + '... (encrypted)'); + }, + disconnect() { + console.log('❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)'); + } + }; +} + +export function createUnencryptedConnection(roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ Connecting to "' + roomId + '... (unencrypted)'); + }, + disconnect() { + console.log('❌ Disconnected from "' + roomId + '" room (unencrypted)'); + } + }; +} +``` + +```css +label { display: block; margin-bottom: 10px; } +``` + +
+ +It is correct that `createConnection` is a dependency. However, this code is a bit fragile because someone could edit the `App` component to pass an inline function as the value of this prop. In that case, its value would be different every time the `App` component re-renders, so the Effect might re-synchronize too often. To avoid this, you can pass `isEncrypted` down instead: + + + +```js App.js +import { useState } from 'react'; +import ChatRoom from './ChatRoom.js'; + +export default function App() { + const [roomId, setRoomId] = useState('general'); + const [isEncrypted, setIsEncrypted] = useState(false); + return ( + <> + + +
+ + + ); +} +``` + +```js ChatRoom.js active +import { useState, useEffect } from 'react'; +import { + createEncryptedConnection, + createUnencryptedConnection, +} from './chat.js'; + +export default function ChatRoom({ roomId, isEncrypted }) { + useEffect(() => { + const createConnection = isEncrypted ? + createEncryptedConnection : + createUnencryptedConnection; + const connection = createConnection(roomId); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, isEncrypted]); + + return

Welcome to the {roomId} room!

; +} +``` + +```js chat.js +export function createEncryptedConnection(roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ 🔐 Connecting to "' + roomId + '... (encrypted)'); + }, + disconnect() { + console.log('❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)'); + } + }; +} + +export function createUnencryptedConnection(roomId) { + // A real implementation would actually connect to the server + return { + connect() { + console.log('✅ Connecting to "' + roomId + '... (unencrypted)'); + }, + disconnect() { + console.log('❌ Disconnected from "' + roomId + '" room (unencrypted)'); + } + }; +} +``` + +```css +label { display: block; margin-bottom: 10px; } +``` + +
+ +In this version, the `App` component passes a boolean prop instead of a function. Inside the Effect, you decide which function to use. Since both `createEncryptedConnection` and `createUnencryptedConnection` are declared outside the component, they aren't reactive, and don't need to be dependencies. You'll learn more about this in [Removing Effect Dependencies](/learn/removing-effect-dependencies). + +
+ +### Populate a chain of select boxes {/*populate-a-chain-of-select-boxes*/} + +In this example, there are two select boxes. One select box lets the user picks a planet. Another select box lets the user pick a place *on that planet.* The second box doesn't work yet. Your task is to make it show the places on the chosen planet. + +Look at how the first select box works. It populates the `planetList` state with the result from the `"/planets"` API call. The currently selected planet's ID is kept in the `planetId` state variable. You need to find where to add some additional code so that the `placeList` state variable is populated with the result of the `"/planets/" + planetId + "/places"` API call. + +If you implement this right, selecting a planet should populate the place list. Changing a planet should change the place list. + + + +If you have two independent synchronization processes, you need to write two separate Effects. + + + + + +```js App.js +import { useState, useEffect } from 'react'; +import { fetchData } from './api.js'; + +export default function Page() { + const [planetList, setPlanetList] = useState([]) + const [planetId, setPlanetId] = useState(''); + + const [placeList, setPlaceList] = useState([]); + const [placeId, setPlaceId] = useState(''); + + useEffect(() => { + let ignore = false; + fetchData('/planets').then(result => { + if (!ignore) { + console.log('Fetched a list of planets.'); + setPlanetList(result); + setPlanetId(result[0].id); // Select the first planet + } + }); + return () => { + ignore = true; + } + }, []); + + return ( + <> + + +
+

You are going to: {placeId || '???'} on {planetId || '???'}

+ + ); +} +``` + +```js api.js hidden +export function fetchData(url) { + if (url === '/planets') { + return fetchPlanets(); + } else if (url.startsWith('/planets/')) { + const match = url.match(/^\/planets\/([\w-]+)\/places(\/)?$/); + if (!match || !match[1] || !match[1].length) { + throw Error('Expected URL like "/planets/earth/places". Received: "' + url + '".'); + } + return fetchPlaces(match[1]); + } else throw Error('Expected URL like "/planets" or "/planets/earth/places". Received: "' + url + '".'); +} + +async function fetchPlanets() { + return new Promise(resolve => { + setTimeout(() => { + resolve([{ + id: 'earth', + name: 'Earth' + }, { + id: 'venus', + name: 'Venus' + }, { + id: 'mars', + name: 'Mars' + }]); + }, 1000); + }); +} + +async function fetchPlaces(planetId) { + if (typeof planetId !== 'string') { + throw Error( + 'fetchPlaces(planetId) expects a string argument. ' + + 'Instead received: ' + planetId + '.' + ); + } + return new Promise(resolve => { + setTimeout(() => { + if (planetId === 'earth') { + resolve([{ + id: 'laos', + name: 'Laos' + }, { + id: 'spain', + name: 'Spain' + }, { + id: 'vietnam', + name: 'Vietnam' + }]); + } else if (planetId === 'venus') { + resolve([{ + id: 'aurelia', + name: 'Aurelia' + }, { + id: 'diana-chasma', + name: 'Diana Chasma' + }, { + id: 'kumsong-vallis', + name: 'Kŭmsŏng Vallis' + }]); + } else if (planetId === 'mars') { + resolve([{ + id: 'aluminum-city', + name: 'Aluminum City' + }, { + id: 'new-new-york', + name: 'New New York' + }, { + id: 'vishniac', + name: 'Vishniac' + }]); + } else throw Error('Uknown planet ID: ' + planetId); + }, 1000); + }); +} +``` + +```css +label { display: block; margin-bottom: 10px; } +``` + +
+ + + +There are two independent synchronization processes: + +- The first select box is synchronized to the remote list of planets. +- The second select box is synchronized to the remote list of places for the current `planetId`. + +This is why it makes sense to describe them as two separate Effects. Here's an example of how you could do this: + + + +```js App.js +import { useState, useEffect } from 'react'; +import { fetchData } from './api.js'; + +export default function Page() { + const [planetList, setPlanetList] = useState([]) + const [planetId, setPlanetId] = useState(''); + + const [placeList, setPlaceList] = useState([]); + const [placeId, setPlaceId] = useState(''); + + useEffect(() => { + let ignore = false; + fetchData('/planets').then(result => { + if (!ignore) { + console.log('Fetched a list of planets.'); + setPlanetList(result); + setPlanetId(result[0].id); // Select the first planet + } + }); + return () => { + ignore = true; + } + }, []); + + useEffect(() => { + if (planetId === '') { + // Nothing is selected in the first box yet + return; + } + + let ignore = false; + fetchData('/planets/' + planetId + '/places').then(result => { + if (!ignore) { + console.log('Fetched a list of places on "' + planetId + '".'); + setPlaceList(result); + setPlaceId(result[0].id); // Select the first place + } + }); + return () => { + ignore = true; + } + }, [planetId]); + + return ( + <> + + +
+

You are going to: {placeId || '???'} on {planetId || '???'}

+ + ); +} +``` + +```js api.js hidden +export function fetchData(url) { + if (url === '/planets') { + return fetchPlanets(); + } else if (url.startsWith('/planets/')) { + const match = url.match(/^\/planets\/([\w-]+)\/places(\/)?$/); + if (!match || !match[1] || !match[1].length) { + throw Error('Expected URL like "/planets/earth/places". Received: "' + url + '".'); + } + return fetchPlaces(match[1]); + } else throw Error('Expected URL like "/planets" or "/planets/earth/places". Received: "' + url + '".'); +} + +async function fetchPlanets() { + return new Promise(resolve => { + setTimeout(() => { + resolve([{ + id: 'earth', + name: 'Earth' + }, { + id: 'venus', + name: 'Venus' + }, { + id: 'mars', + name: 'Mars' + }]); + }, 1000); + }); +} + +async function fetchPlaces(planetId) { + if (typeof planetId !== 'string') { + throw Error( + 'fetchPlaces(planetId) expects a string argument. ' + + 'Instead received: ' + planetId + '.' + ); + } + return new Promise(resolve => { + setTimeout(() => { + if (planetId === 'earth') { + resolve([{ + id: 'laos', + name: 'Laos' + }, { + id: 'spain', + name: 'Spain' + }, { + id: 'vietnam', + name: 'Vietnam' + }]); + } else if (planetId === 'venus') { + resolve([{ + id: 'aurelia', + name: 'Aurelia' + }, { + id: 'diana-chasma', + name: 'Diana Chasma' + }, { + id: 'kumsong-vallis', + name: 'Kŭmsŏng Vallis' + }]); + } else if (planetId === 'mars') { + resolve([{ + id: 'aluminum-city', + name: 'Aluminum City' + }, { + id: 'new-new-york', + name: 'New New York' + }, { + id: 'vishniac', + name: 'Vishniac' + }]); + } else throw Error('Uknown planet ID: ' + planetId); + }, 1000); + }); +} +``` + +```css +label { display: block; margin-bottom: 10px; } +``` + +
+ +This code is a bit repetitive. However, that's not a good reason to combine it into a single Effect! If you did this, you'd have to combine both Effect's dependencies into one list, and then changing the planet would refetch the list of all planets. Effects are not a tool for code reuse. + +Instead, to reduce repetition, you can extract some logic into a custom Hook like `useSelectOptions` below: + + + +```js App.js +import { useState } from 'react'; +import { useSelectOptions } from './useSelectOptions.js'; + +export default function Page() { + const [ + planetList, + planetId, + setPlanetId + ] = useSelectOptions('/planets'); + + const [ + placeList, + placeId, + setPlaceId + ] = useSelectOptions(planetId ? `/planets/${planetId}/places` : null); + + return ( + <> + + +
+

You are going to: {placeId || '...'} on {planetId || '...'}

+ + ); +} +``` + +```js useSelectOptions.js +import { useState, useEffect } from 'react'; +import { fetchData } from './api.js'; + +export function useSelectOptions(url) { + const [list, setList] = useState(null); + const [selectedId, setSelectedId] = useState(''); + useEffect(() => { + if (url === null) { + return; + } + + let ignore = false; + fetchData(url).then(result => { + if (!ignore) { + setList(result); + setSelectedId(result[0].id); + } + }); + return () => { + ignore = true; + } + }, [url]); + return [list, selectedId, setSelectedId]; +} +``` + +```js api.js hidden +export function fetchData(url) { + if (url === '/planets') { + return fetchPlanets(); + } else if (url.startsWith('/planets/')) { + const match = url.match(/^\/planets\/([\w-]+)\/places(\/)?$/); + if (!match || !match[1] || !match[1].length) { + throw Error('Expected URL like "/planets/earth/places". Received: "' + url + '".'); + } + return fetchPlaces(match[1]); + } else throw Error('Expected URL like "/planets" or "/planets/earth/places". Received: "' + url + '".'); +} + +async function fetchPlanets() { + return new Promise(resolve => { + setTimeout(() => { + resolve([{ + id: 'earth', + name: 'Earth' + }, { + id: 'venus', + name: 'Venus' + }, { + id: 'mars', + name: 'Mars' + }]); + }, 1000); + }); +} + +async function fetchPlaces(planetId) { + if (typeof planetId !== 'string') { + throw Error( + 'fetchPlaces(planetId) expects a string argument. ' + + 'Instead received: ' + planetId + '.' + ); + } + return new Promise(resolve => { + setTimeout(() => { + if (planetId === 'earth') { + resolve([{ + id: 'laos', + name: 'Laos' + }, { + id: 'spain', + name: 'Spain' + }, { + id: 'vietnam', + name: 'Vietnam' + }]); + } else if (planetId === 'venus') { + resolve([{ + id: 'aurelia', + name: 'Aurelia' + }, { + id: 'diana-chasma', + name: 'Diana Chasma' + }, { + id: 'kumsong-vallis', + name: 'Kŭmsŏng Vallis' + }]); + } else if (planetId === 'mars') { + resolve([{ + id: 'aluminum-city', + name: 'Aluminum City' + }, { + id: 'new-new-york', + name: 'New New York' + }, { + id: 'vishniac', + name: 'Vishniac' + }]); + } else throw Error('Uknown planet ID: ' + planetId); + }, 1000); + }); +} +``` + +```css +label { display: block; margin-bottom: 10px; } +``` + +
+ +Check the `useSelectOptions.js` tab in the sandbox to see how it works. Ideally, most Effects in your application should eventually be replaced by custom Hooks, whether written by you or by the community. Custom Hooks hide the synchronization logic, so the calling component doesn't know about the Effect. As you keep working on your app, you'll develop a palette of Hooks to choose from, and eventually you won't need to write Effects in your components very often. + +
+ + diff --git a/beta/src/pages/learn/synchronizing-with-effects.md b/beta/src/pages/learn/synchronizing-with-effects.md index 36f67ed0..e97d37a7 100644 --- a/beta/src/pages/learn/synchronizing-with-effects.md +++ b/beta/src/pages/learn/synchronizing-with-effects.md @@ -399,7 +399,7 @@ video { width: 250px; } The dependency array can contain multiple dependencies. React will only skip re-running the Effect if *all* of the dependencies you specify have exactly the same values as they had during the previous render. React compares the dependency values using the [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) comparison. See the [`useEffect` API reference](/apis/useeffect#reference) for more details. -**Notice that you can't "choose" your dependencies.** You will get a lint error if the dependencies you specified don't match what React expects based on the code inside your Effect. This helps catch many bugs in your code. If your Effect uses some value but you *don't* want to re-run the Effect when it changes, you'll need to *edit the Effect code itself* to not "need" that dependency. Learn more about this in [Specifying the Effect Dependencies](/learn/specifying-effect-dependencies). +**Notice that you can't "choose" your dependencies.** You will get a lint error if the dependencies you specified don't match what React expects based on the code inside your Effect. This helps catch many bugs in your code. If your Effect uses some value but you *don't* want to re-run the Effect when it changes, you'll need to [*edit the Effect code itself* to not "need" that dependency.](/learn/lifecycle-of-reactive-effects#what-to-do-when-you-dont-want-to-re-synchronize) @@ -505,10 +505,10 @@ export function createConnection() { // A real implementation would actually connect to the server return { connect() { - console.log('Connecting...'); + console.log('✅ Connecting...'); }, disconnect() { - console.log('Disconnected.'); + console.log('❌ Disconnected.'); } }; } @@ -520,11 +520,11 @@ input { display: block; margin-bottom: 20px; } -This Effect only runs on mount, so you might expect `"Connecting..."` to be printed once in the console. **However, if you check the console, `"Connecting..."` gets printed twice. Why does it happen?** +This Effect only runs on mount, so you might expect `"✅ Connecting..."` to be printed once in the console. **However, if you check the console, `"✅ Connecting..."` gets printed twice. Why does it happen?** Imagine the `ChatRoom` component is a part of a larger app with many different screens. The user starts their journey on the `ChatRoom` page. The component mounts and calls `connection.connect()`. Then imagine the user navigates to another screen--for example, to the Settings page. The `ChatRoom` component unmounts. Finally, the user clicks Back and `ChatRoom` mounts again. This would set up a second connection--but the first connection was never destroyed! As the user navigates across the app, the connections would keep piling up. -Bugs like this are easy to miss without extensive manual testing. To help you spot them quickly, in development React remounts every component once immediately after its initial mount. **Seeing the `"Connecting..."` log twice helps you notice the real issue: your code doesn't close the connection when the component unmounts.** +Bugs like this are easy to miss without extensive manual testing. To help you spot them quickly, in development React remounts every component once immediately after its initial mount. **Seeing the `"✅ Connecting..."` log twice helps you notice the real issue: your code doesn't close the connection when the component unmounts.** To fix the issue, return a *cleanup function* from your Effect: @@ -561,10 +561,10 @@ export function createConnection() { // A real implementation would actually connect to the server return { connect() { - console.log('Connecting...'); + console.log('✅ Connecting...'); }, disconnect() { - console.log('Disconnected.'); + console.log('❌ Disconnected.'); } }; } @@ -578,13 +578,13 @@ input { display: block; margin-bottom: 20px; } Now you get three console logs in development: -1. `"Connecting..."` -2. `"Disconnected."` -3. `"Connecting..."` +1. `"✅ Connecting..."` +2. `"❌ Disconnected."` +3. `"✅ Connecting..."` **This is the correct behavior in development.** By remounting your component, React verifies that navigating away and back would not break your code. Disconnecting and then connecting again is exactly what should happen! When you implement the cleanup well, there should be no user-visible difference between running the Effect once vs running it, cleaning it up, and running it again. There's an extra connect/disconnect call pair because React is probing your code for bugs in development. This is normal and you shouldn't try to make it go away. -**In production, you would only see `"Connecting..."` printed once.** Remounting components only happens in development to help you find Effects that need cleanup. You can turn off [Strict Mode](/apis/strictmode) to opt out of the development behavior, but we recommend keeping it on. This lets you find many bugs like the one above. +**In production, you would only see `"✅ Connecting..."` printed once.** Remounting components only happens in development to help you find Effects that need cleanup. You can turn off [Strict Mode](/apis/strictmode) to opt out of the development behavior, but we recommend keeping it on. This lets you find many bugs like the one above. ## How to handle the Effect firing twice in development? {/*how-to-handle-the-effect-firing-twice-in-development*/} diff --git a/beta/src/pages/learn/updating-objects-in-state.md b/beta/src/pages/learn/updating-objects-in-state.md index d5bdcb63..81c5c4a3 100644 --- a/beta/src/pages/learn/updating-objects-in-state.md +++ b/beta/src/pages/learn/updating-objects-in-state.md @@ -84,7 +84,7 @@ export default function MovingDot() { height: 20, }} />
- ) + ); } ``` @@ -156,7 +156,7 @@ export default function MovingDot() { height: 20, }} />
- ) + ); } ``` diff --git a/beta/src/pages/learn/you-might-not-need-an-effect.md b/beta/src/pages/learn/you-might-not-need-an-effect.md index dc4c556d..b9ed7051 100644 --- a/beta/src/pages/learn/you-might-not-need-an-effect.md +++ b/beta/src/pages/learn/you-might-not-need-an-effect.md @@ -358,6 +358,98 @@ function Form() { When you choose whether to put some logic into an event handler or an Effect, the main question you need to answer is _what kind of logic_ it is from the user's perspective. If this logic is caused by a particular interaction, keep it in the event handler. If it's caused by the user _seeing_ the component on the screen, keep it in the Effect. +### Chains of computations {/*chains-of-computations*/} + +Sometimes you might feel tempted to chain Effects that each adjust a piece of state based on other state: + +```js {7-29} +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]); + + function handlePlaceCard(nextCard) { + if (isGameOver) { + throw Error('Game already ended.'); + } else { + setCard(nextCard); + } + } + + // ... +``` + +There are two problems with this code. + +One problem is that it is very inefficient: the component (and its children) have to re-render between each `set` call in the chain. In the example above, in the worst case (`setCard` → render → `setGoldCardCount` → render → `setRound` → render → `setIsGameOver` → render) there are three unnecessary re-renders of the tree below. + +Even if it weren't slow, as your code evolves, you will run into cases where the "chain" you wrote doesn't fit the new requirements. Imagine you are adding a way to step through the history of the game moves. You'd do it by updating each state variable to a value from the past. However, setting the `card` state to a value from the past would trigger the Effect chain again and change the data you're showing. Code like this is often rigid and fragile. + +In this case, it's better to calculate what you can during rendering, and adjust the state in the event handler: + +```js {6-7,14-26} +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, now you will be able to set each state variable to a move from the past without triggering the Effect chain that adjusts every other value. If you need to reuse logic between several event handlers, you can [extract a function](#sharing-logic-between-event-handlers) and call it from those handlers. + +Remember that inside event handlers, [state behaves like a snapshot](/learn/state-as-a-snapshot). For example, even after you call `setRound(rount + 1)`, the `round` variable will reflect the value at the time the user clicked the button. If you need to use the next value for calculations, define it manually like `const nextRound = round + 1`. + +In some cases, you *can't* calculate the next state directly in the event handler. For example, imagine a form with multiple dropdowns where the options of each next dropdown depend on the selected value of the previous dropdown. Then, [a chain of Effects fetching data](/learn/adjusting-effect-dependencies#splitting-an-effect-in-two) is appropriate because you are synchronizing with network. + ### Initializing the application {/*initializing-the-application*/} Some logic should only run once when the app loads. You might place it in an Effect in the top-level component: @@ -651,7 +743,7 @@ This ensures that when your Effect fetches data, all responses except the last r Handling race conditions is not the only difficulty with implementing data fetching. You might also want to think about how to cache the responses (so that the user can click Back and see the previous screen instantly instead of a spinner), how to fetch them on the server (so that the initial server-rendered HTML contains the fetched content instead of a spinner), and how to avoid network waterfalls (so that a child component that needs to fetch data doesn't have to wait for every parent above it to finish fetching their data before it can start). **These issues apply to any UI library, not just React. Solving them is not trivial, which is why modern [frameworks](/learn/start-a-new-react-project#building-with-a-full-featured-framework) provide more efficient built-in data fetching mechanisms than writing Effects directly in your components.** -If you don't use a framework (and don't want to build your own) but would like to make data fetching from Effects more ergonomic, consider extracting your fetching logic into a custom Hook like in this example: +If you don't use a framework (and don't want to build your own) but would like to make data fetching from Effects more ergonomic, consider extracting [your fetching logic into a custom Hook](/learn/adjusting-effect-dependencies#wrapping-an-effect-into-a-custom-hook) like in this example: ```js {4} function SearchResults({ query }) { @@ -666,21 +758,21 @@ function SearchResults({ query }) { } function useData(url) { - const [result, setResult] = useState(null); + const [data, setData] = useState(null); useEffect(() => { let ignore = false; fetch(url) .then(response => response.json()) .then(json => { if (!ignore) { - setResult(json); + setData(json); } }); return () => { ignore = true; }; }, [url]); - return result; + return data; } ``` diff --git a/beta/src/sidebarLearn.json b/beta/src/sidebarLearn.json index 007150fe..90feb1da 100644 --- a/beta/src/sidebarLearn.json +++ b/beta/src/sidebarLearn.json @@ -170,6 +170,10 @@ { "title": "You Might Not Need an Effect", "path": "/learn/you-might-not-need-an-effect" + }, + { + "title": "Lifecycle of Reactive Effects", + "path": "/learn/lifecycle-of-reactive-effects" } ] }