From 59c68a7baf828c9eea7d28aae8b01d0cceb9cc5e Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 2 Sep 2022 01:04:07 +0100 Subject: [PATCH] [Beta] Reusing Logic with Custom Hooks (#4958) --- .../learn/removing-effect-dependencies.md | 2 +- .../learn/reusing-logic-with-custom-hooks.md | 1407 +++++++++++++++++ beta/src/sidebarLearn.json | 4 + 3 files changed, 1412 insertions(+), 1 deletion(-) create mode 100644 beta/src/pages/learn/reusing-logic-with-custom-hooks.md diff --git a/beta/src/pages/learn/removing-effect-dependencies.md b/beta/src/pages/learn/removing-effect-dependencies.md index 42f86c2d..fe479d27 100644 --- a/beta/src/pages/learn/removing-effect-dependencies.md +++ b/beta/src/pages/learn/removing-effect-dependencies.md @@ -547,7 +547,7 @@ function ShippingForm({ country }) { Now the first Effect only re-runs if the `country` changes, while the second Effect re-runs when the `city` changes. You've separated them by purpose: two different things are synchronized by two separate Effects. Two separate Effects have two separate dependency lists, so they will no longer trigger each other unintentionally. -The final code is longer than the original, but splitting these Effects is still correct. [Each Effect should represent an independent synchronization process.](/learn/lifecycle-of-reactive-effects#each-effect-represents-a-separate-synchronization-process) In this example, deleting one Effect doesn't break the other Effect's logic. This is a good indication that they *synchronize different things,* and it made sense to split them up. +The final code is longer than the original, but splitting these Effects is still correct. [Each Effect should represent an independent synchronization process.](/learn/lifecycle-of-reactive-effects#each-effect-represents-a-separate-synchronization-process) In this example, deleting one Effect doesn't break the other Effect's logic. This is a good indication that they *synchronize different things,* and it made sense to split them up. If the duplication feels concerning, you can further improve this code by [extracting repetitive logic into a custom Hook.](/learn/reusing-logic-with-custom-hooks#when-to-use-custom-hooks) ### Are you reading some state to calculate the next state? {/*are-you-reading-some-state-to-calculate-the-next-state*/} diff --git a/beta/src/pages/learn/reusing-logic-with-custom-hooks.md b/beta/src/pages/learn/reusing-logic-with-custom-hooks.md new file mode 100644 index 00000000..fcd64992 --- /dev/null +++ b/beta/src/pages/learn/reusing-logic-with-custom-hooks.md @@ -0,0 +1,1407 @@ +--- +title: 'Reusing Logic with Custom Hooks' +--- + + + +React comes with several built-in Hooks like `useState`, `useContext`, and `useEffect`. Sometimes, you'll wish that there was a Hook for some more specific purpose: for example, to fetch data, to keep track of whether the user is online, or to connect to a chat room. You might not find these Hooks in React, but you can create your own Hooks for your application's needs. + + + + + +- What custom Hooks are, and how to write your own +- How to use reuse logic between components +- How to name and structure your custom Hooks +- When and why to extract custom Hooks + + + +## Custom Hooks: Sharing logic between components {/*custom-hooks-sharing-logic-between-components*/} + +Imagine you're developing an app that heavily relies on the network (as most apps do). You want to warn the user if their network connection has accidentally gone off while they were using your app. How would you go about it? + +It seems like you'll need two things in your component: + +1. A piece of state that tracks whether the network is online. +2. An Effect that subscribes to the global [`online`](https://developer.mozilla.org/en-US/docs/Web/API/Window/online_event) and [`offline`](https://developer.mozilla.org/en-US/docs/Web/API/Window/offline_event) events, and updates that state. + +This will keep your component [synchronized](/learn/synchronizing-with-effects) with the network status. You might start with something like this: + + + +```js +import { useState, useEffect } from 'react'; + +export default function StatusBar() { + const [isOnline, setIsOnline] = useState(true); + useEffect(() => { + function handleOnline() { + setIsOnline(true); + } + function handleOffline() { + setIsOnline(false); + } + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + return

You are {isOnline ? 'online' : 'offline'}!

; +} +``` + +
+ +Try turning your network on and off, and notice how this `StatusBar` updates in response to your actions. + +Now imagine you *also* want to use the same logic in a different component. You want to implement a Save button that will become disabled and show "Reconnecting..." instead of "Save" while the network is off. + +To start, you can copy and paste the `isOnline` state and the Effect into `SaveButton`: + + + +```js +import { useState, useEffect } from 'react'; + +export default function SaveButton() { + const [isOnline, setIsOnline] = useState(true); + useEffect(() => { + function handleOnline() { + setIsOnline(true); + } + function handleOffline() { + setIsOnline(false); + } + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + function handleSaveClick() { + console.log('✅ Progress saved'); + } + + return ( + + ); +} +``` + + + +Verify that, if you turn off the network, the button will change its appearance. + +These two components work fine, but the duplication in logic between them is unfortunate. It seems like even though they have different *visual appearance,* you want to reuse the logic between them. + +### Extracting your own custom Hook from a component {/*extracting-your-own-custom-hook-from-a-component*/} + +Imagine for a moment that, similar to [`useState`](/apis/react/useState) and [`useEffect`](/apis/useEffect), there was a built-in `useOnlineStatus` Hook. Then both of these components could be simplified and you could remove the duplication between them: + +```js {2,7} +function StatusBar() { + const isOnline = useOnlineStatus(); + return

You are {isOnline ? 'online' : 'offline'}!

; +} + +function SaveButton() { + const isOnline = useOnlineStatus(); + + function handleSaveClick() { + console.log('✅ Progress saved'); + } + + return ( + + ); +} +``` + +Although there is no such built-in Hook, you can write it yourself. Declare a function called `useOnlineStatus()` and move all the duplicated code into it from the components you wrote earlier: + +```js {2-16} +function useOnlineStatus() { + const [isOnline, setIsOnline] = useState(true); + useEffect(() => { + function handleOnline() { + setIsOnline(true); + } + function handleOffline() { + setIsOnline(false); + } + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + return isOnline; +} +``` + +At the end of the function, return `isOnline`. This lets your components read that value: + + + +```js +import { useOnlineStatus } from './useOnlineStatus.js'; + +function StatusBar() { + const isOnline = useOnlineStatus(); + return

You are {isOnline ? 'online' : 'offline'}!

; +} + +function SaveButton() { + const isOnline = useOnlineStatus(); + + function handleSaveClick() { + console.log('✅ Progress saved'); + } + + return ( + + ); +} + +export default function App() { + return ( + <> + + + + ); +} +``` + +```js useOnlineStatus.js +import { useState, useEffect } from 'react'; + +export function useOnlineStatus() { + const [isOnline, setIsOnline] = useState(true); + useEffect(() => { + function handleOnline() { + setIsOnline(true); + } + function handleOffline() { + setIsOnline(false); + } + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + return isOnline; +} +``` + +
+ +Verify that switching the network on and off updates both components. + +Now your components don't have as much repetitive logic. **More importantly, the code inside them describes *what they want to do* (use the online status!) rather than *how to do it* (by subscribing to the browser events).** + +When you extract logic into custom Hooks, you can hide the gnarly details of how you deal with some external system or a browser API. The code of your components expresses your intent, not the implementation. + +### Hook names always start with `use` {/*hook-names-always-start-with-use*/} + +React applications are built from components. Components are built from Hooks, whether built-in or custom. You'll likely often use custom Hooks created by others, but occasionally you might write one yourself! + +You must follow these naming conventions: + +1. **React component names must start with a capital letter,** like `StatusBar` and `SaveButton`. React components also need to return something that React knows how to display, like a piece of JSX. +2. **Hook names must start with `use` followed by a capital letter,** like [`useState`](/apis/react/useState) (built-in) or `useOnlineStatus` (custom, like earlier on the page). Hooks may return arbitrary values. + +This convention guarantees that you can always look at a component and know where its state, Effects, and other React features might "hide". For example, if you see a `getColor()` function call inside your component, you can be sure that it can't possibly contain React state inside because its name doesn't start with `use`. However, a function call like `useOnlineStatus()` will most likely contain calls to other Hooks inside! + + + +If your linter is [configured for React](/learn/editor-setup#linting), it will enforce this naming convention. Scroll up to the sandbox above and rename `useOnlineStatus` to `getOnlineStatus`. Notice that the linter won't allow you to call `useState` or `useEffect` inside of it anymore. Only Hooks and components can call other Hooks! + + + +### Custom Hooks let you share stateful logic, not state itself {/*custom-hooks-let-you-share-stateful-logic-not-state-itself*/} + +In the earlier example, when you turned the network on and off, both components updated together. However, it's wrong to think that a single `isOnline` state variable is shared between them. Look at this code: + +```js {2,7} +function StatusBar() { + const isOnline = useOnlineStatus(); + // ... +} + +function SaveButton() { + const isOnline = useOnlineStatus(); + // ... +} +``` + +It works the same way as before you extracted the duplication: + +```js {2-5,10-13} +function StatusBar() { + const [isOnline, setIsOnline] = useState(true); + useEffect(() => { + // ... + }, []); + // ... +} + +function SaveButton() { + const [isOnline, setIsOnline] = useState(true); + useEffect(() => { + // ... + }, []); + // ... +} +``` + +These are two completely independent state variables and Effects! They only happened to have the same value at the same time because you synchronized them with the same external value (whether the network is on). + +To better illustrate this, we'll need a different example. Consider this `Form` component: + + + +```js +import { useState } from 'react'; + +export default function Form() { + const [firstName, setFirstName] = useState('Mary'); + const [lastName, setLastName] = useState('Poppins'); + + function handleFirstNameChange(e) { + setFirstName(e.target.value); + } + + function handleLastNameChange(e) { + setLastName(e.target.value); + } + + return ( + <> + + +

Good morning, {firstName} {lastName}.

+ + ); +} +``` + +```css +label { display: block; } +input { margin-left: 10px; } +``` + +
+ +There's some repetitive logic for each form field: + +1. There's a piece of state (`firstName` and `lastName`). +1. There's a change handler (`handleFirstNameChange` and `handleLastNameChange`). +1. There's a piece of JSX that specifies the `value` and `onChange` attributes for that input. + +You can extract the repetitive logic into this `useFormInput` custom Hook: + + + +```js +import { useFormInput } from './useFormInput.js'; + +export default function Form() { + const firstNameProps = useFormInput('Mary'); + const lastNameProps = useFormInput('Poppins'); + + return ( + <> + + +

Good morning, {firstNameProps.value} {lastNameProps.value}.

+ + ); +} +``` + +```js useFormInput.js active +import { useState } from 'react'; + +export function useFormInput(initialValue) { + const [value, setValue] = useState(initialValue); + + function handleChange(e) { + setValue(e.target.value); + } + + const inputProps = { + value: value, + onChange: handleChange + }; + + return inputProps; +} +``` + +```css +label { display: block; } +input { margin-left: 10px; } +``` + +
+ +Notice that it only declares *one* state variable called `value`. + +However, the `Form` component calls `useFormInput` *two times:* + +```js +function Form() { + const firstNameProps = useFormInput('Mary'); + const lastNameProps = useFormInput('Poppins'); + // ... +``` + +This is why it works like declaring two separate state variables! + +**Custom Hooks let you share *stateful logic* but not *state itself.* Each call to a Hook is completely independent from every other other call to the same Hook.** This is why the two sandboxes above are completely equivalent. If you'd like, scroll back up and compare them. The behavior before and after extracting a custom Hook is identical. + +When you need to share the state itself between multiple components, [lift it up and pass it down](/learn/sharing-state-between-components) instead. + +## Passing reactive values between Hooks {/*passing-reactive-values-between-hooks*/} + +The code inside your custom Hooks will re-run during every re-render of your component. This is why, like components, custom Hooks [need to be pure.](/learn/keeping-components-pure) Think of custom Hooks' code as part of your component's body! + +Because custom Hooks re-render together with your component, they always receive the latest props and state. To see what this means, consider this chat room example. Change the server URL or the selected chat room: + + + +```js App.js +import { useState } from 'react'; +import ChatRoom from './ChatRoom.js'; + +export default function App() { + const [roomId, setRoomId] = useState('general'); + return ( + <> + +
+ + + ); +} +``` + +```js ChatRoom.js active +import { useState, useEffect } from 'react'; +import { createConnection } from './chat.js'; +import { showNotification } from './notifications.js'; + +export default function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId + }; + const connection = createConnection(options); + connection.on('message', (msg) => { + showNotification('New message: ' + msg); + }); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, serverUrl]); + + return ( + <> + +

Welcome to the {roomId} room!

+ + ); +} +``` + +```js chat.js +export function createConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + if (typeof serverUrl !== 'string') { + throw Error('Expected serverUrl to be a string. Received: ' + serverUrl); + } + if (typeof roomId !== 'string') { + throw Error('Expected roomId to be a string. Received: ' + roomId); + } + let intervalId; + let messageCallback; + return { + connect() { + console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...'); + clearInterval(intervalId); + intervalId = setInterval(() => { + if (messageCallback) { + if (Math.random() > 0.5) { + messageCallback('hey') + } else { + messageCallback('lol'); + } + } + }, 3000); + }, + disconnect() { + clearInterval(intervalId); + messageCallback = null; + console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ''); + }, + on(event, callback) { + if (messageCallback) { + throw Error('Cannot add the handler twice.'); + } + if (event !== 'message') { + throw Error('Only "message" event is supported.'); + } + messageCallback = callback; + }, + }; +} +``` + +```js notifications.js +import Toastify from 'toastify-js'; +import 'toastify-js/src/toastify.css'; + +export function showNotification(message, theme = 'dark') { + Toastify({ + text: message, + duration: 2000, + gravity: 'top', + position: 'right', + style: { + background: theme === 'dark' ? 'black' : 'white', + color: theme === 'dark' ? 'white' : 'black', + }, + }).showToast(); +} +``` + +```json package.json hidden +{ + "dependencies": { + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "toastify-js": "1.12.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```css +input { display: block; margin-bottom: 20px; } +button { margin-left: 10px; } +``` + +
+ +When you change `serverUrl` or `roomId`, the Effect ["reacts" to your changes](/learn/lifecycle-of-reactive-effects#effects-react-to-reactive-values) and re-synchronizes. You can tell by the console messages that the chat re-connects every time that you change your Effect's dependencies. + +Now move the Effect's code into a custom Hook: + +```js {2-13} +export function useChatRoom({ serverUrl, roomId }) { + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId + }; + const connection = createConnection(options); + connection.connect(); + connection.on('message', (msg) => { + showNotification('New message: ' + msg); + }); + return () => connection.disconnect(); + }, [roomId, serverUrl]); +} +``` + +This lets your `ChatRoom` component call your custom Hook without worrying about how it works inside: + +```js {4-7} +export default function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl + }); + + return ( + <> + +

Welcome to the {roomId} room!

+ + ); +} +``` + +This looks much simpler! (But it does the same thing.) + +Notice that the logic *still responds* to prop and state changes. Try editing the server URL or the selected room: + + + +```js App.js +import { useState } from 'react'; +import ChatRoom from './ChatRoom.js'; + +export default function App() { + const [roomId, setRoomId] = useState('general'); + return ( + <> + +
+ + + ); +} +``` + +```js ChatRoom.js active +import { useState } from 'react'; +import { useChatRoom } from './useChatRoom.js'; + +export default function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl + }); + + return ( + <> + +

Welcome to the {roomId} room!

+ + ); +} +``` + +```js useChatRoom.js +import { useEffect } from 'react'; +import { createConnection } from './chat.js'; +import { showNotification } from './notifications.js'; + +export function useChatRoom({ serverUrl, roomId }) { + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId + }; + const connection = createConnection(options); + connection.connect(); + connection.on('message', (msg) => { + showNotification('New message: ' + msg); + }); + return () => connection.disconnect(); + }, [roomId, serverUrl]); +} +``` + +```js chat.js +export function createConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + if (typeof serverUrl !== 'string') { + throw Error('Expected serverUrl to be a string. Received: ' + serverUrl); + } + if (typeof roomId !== 'string') { + throw Error('Expected roomId to be a string. Received: ' + roomId); + } + let intervalId; + let messageCallback; + return { + connect() { + console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...'); + clearInterval(intervalId); + intervalId = setInterval(() => { + if (messageCallback) { + if (Math.random() > 0.5) { + messageCallback('hey') + } else { + messageCallback('lol'); + } + } + }, 3000); + }, + disconnect() { + clearInterval(intervalId); + messageCallback = null; + console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ''); + }, + on(event, callback) { + if (messageCallback) { + throw Error('Cannot add the handler twice.'); + } + if (event !== 'message') { + throw Error('Only "message" event is supported.'); + } + messageCallback = callback; + }, + }; +} +``` + +```js notifications.js +import Toastify from 'toastify-js'; +import 'toastify-js/src/toastify.css'; + +export function showNotification(message, theme = 'dark') { + Toastify({ + text: message, + duration: 2000, + gravity: 'top', + position: 'right', + style: { + background: theme === 'dark' ? 'black' : 'white', + color: theme === 'dark' ? 'white' : 'black', + }, + }).showToast(); +} +``` + +```json package.json hidden +{ + "dependencies": { + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "toastify-js": "1.12.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```css +input { display: block; margin-bottom: 20px; } +button { margin-left: 10px; } +``` + +
+ +Notice how you're taking the return value of one Hook: + +```js {2} +export default function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl + }); + // ... +``` + +and pass it as an input to another Hook: + +```js {6} +export default function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl + }); + // ... +``` + +Every time your `ChatRoom` component re-renders, it passes the latest `roomId` and `serverUrl` to your Hook. This is why your Effect re-connects to the chat whenever their values are different after a re-render. (If you ever worked with music processing software, chaining Hooks like this might remind you of chaining multiple audio effects, like adding reverb or chorus. It's as if the output of `useState` "feeds into" the input of the `useChatRoom`.) + +### Passing event handlers to custom Hooks {/*passing-event-handlers-to-custom-hooks*/} + + + +This section describes an **experimental API that has not yet been added to React,** so you can't use it yet. + + + +As you start using `useChatRoom` in more components, you might want to let different components customize its behavior. For example, currently, the logic for what to do when a message arrives is hardcoded inside the Hook: + +```js {9-11} +export function useChatRoom({ serverUrl, roomId }) { + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId + }; + const connection = createConnection(options); + connection.connect(); + connection.on('message', (msg) => { + showNotification('New message: ' + msg); + }); + return () => connection.disconnect(); + }, [roomId, serverUrl]); +} +``` + +Let's say you want to move this logic back to your component: + +```js {7-9} +export default function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl, + onReceiveMessage(msg) { + showNotification('New message: ' + msg); + } + }); + // ... +``` + +To make this work, change your custom Hook to take `onReceiveMessage` as one of its named options: + +```js {1,10,13} +export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) { + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId + }; + const connection = createConnection(options); + connection.connect(); + connection.on('message', (msg) => { + onReceiveMessage(msg); + }); + return () => connection.disconnect(); + }, [roomId, serverUrl, onReceiveMessage]); // ✅ All dependencies declared +} +``` + +This will work, but there's one more improvement you can do when your custom Hook accepts event handlers. + +Adding a dependency on `onReceiveMessage` is not ideal because it will cause the chat to re-connect every time the component re-renders. [Wrap this event handler into an Event function to remove it from the dependencies:](/learn/removing-effect-dependencies#wrapping-an-event-handler-from-the-props) + +```js {1,4,5,15,18} +import { useEffect, useEvent } from 'react'; +// ... + +export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) { + const onMessage = useEvent(onReceiveMessage); + + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId + }; + const connection = createConnection(options); + connection.connect(); + connection.on('message', (msg) => { + onMessage(msg); + }); + return () => connection.disconnect(); + }, [roomId, serverUrl]); // ✅ All dependencies declared +} +``` + +Now the chat won't re-connect every time that the `ChatRoom` component re-renders. Here is a fully working demo of passing an event handler to a custom Hook that you can play with: + + + +```js App.js +import { useState } from 'react'; +import ChatRoom from './ChatRoom.js'; + +export default function App() { + const [roomId, setRoomId] = useState('general'); + return ( + <> + +
+ + + ); +} +``` + +```js ChatRoom.js active +import { useState } from 'react'; +import { useChatRoom } from './useChatRoom.js'; +import { showNotification } from './notifications.js'; + +export default function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + useChatRoom({ + roomId: roomId, + serverUrl: serverUrl, + onReceiveMessage(msg) { + showNotification('New message: ' + msg); + } + }); + + return ( + <> + +

Welcome to the {roomId} room!

+ + ); +} +``` + +```js useChatRoom.js +import { useEffect } from 'react'; +import { useEvent } from './useEvent.js'; +import { createConnection } from './chat.js'; + +export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) { + const onMessage = useEvent(onReceiveMessage); + + useEffect(() => { + const options = { + serverUrl: serverUrl, + roomId: roomId + }; + const connection = createConnection(options); + connection.connect(); + connection.on('message', (msg) => { + onMessage(msg); + }); + return () => connection.disconnect(); + }, [roomId, serverUrl, onMessage]); // TODO: Linter will allow [roomId, serverUrl] +} +``` + +```js useEvent.js +import { useRef, useInsertionEffect, useCallback } from 'react'; + +// The useEvent API has not yet been added to React, +// so this is a temporary shim to make this sandbox work. +// You're not expected to write code like this yourself. + +export function useEvent(fn) { + const ref = useRef(null); + useInsertionEffect(() => { + ref.current = fn; + }, [fn]); + return useCallback((...args) => { + const f = ref.current; + return f(...args); + }, []); +} +``` + +```js chat.js +export function createConnection({ serverUrl, roomId }) { + // A real implementation would actually connect to the server + if (typeof serverUrl !== 'string') { + throw Error('Expected serverUrl to be a string. Received: ' + serverUrl); + } + if (typeof roomId !== 'string') { + throw Error('Expected roomId to be a string. Received: ' + roomId); + } + let intervalId; + let messageCallback; + return { + connect() { + console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...'); + clearInterval(intervalId); + intervalId = setInterval(() => { + if (messageCallback) { + if (Math.random() > 0.5) { + messageCallback('hey') + } else { + messageCallback('lol'); + } + } + }, 3000); + }, + disconnect() { + clearInterval(intervalId); + messageCallback = null; + console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl + ''); + }, + on(event, callback) { + if (messageCallback) { + throw Error('Cannot add the handler twice.'); + } + if (event !== 'message') { + throw Error('Only "message" event is supported.'); + } + messageCallback = callback; + }, + }; +} +``` + +```js notifications.js +import Toastify from 'toastify-js'; +import 'toastify-js/src/toastify.css'; + +export function showNotification(message, theme = 'dark') { + Toastify({ + text: message, + duration: 2000, + gravity: 'top', + position: 'right', + style: { + background: theme === 'dark' ? 'black' : 'white', + color: theme === 'dark' ? 'white' : 'black', + }, + }).showToast(); +} +``` + +```json package.json hidden +{ + "dependencies": { + "react": "latest", + "react-dom": "latest", + "react-scripts": "latest", + "toastify-js": "1.12.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + } +} +``` + +```css +input { display: block; margin-bottom: 20px; } +button { margin-left: 10px; } +``` + +
+ +Notice how you no longer need to know *how* `useChatRoom` works in order to use it. You could add it to any other component, pass any other options, and it would work the same way. That's the power of custom Hooks. + +## When to use custom Hooks {/*when-to-use-custom-hooks*/} + +You don't need to extract a custom Hook for every little duplicated bit of code. Some duplication is fine. For example, extracting a `useFormInput` Hook to wrap a single `useState` call like earlier is probably unnecessary. + +However, whenever you write an Effect, consider whether it would be clearer to also wrap it in a custom Hook. [You shouldn't need Effects very often,](/learn/you-might-not-need-an-effect) so if you're writing one, it means that you need to "step outside React" to synchronize with some external system or to do something that React doesn't have a built-in API for. Wrapping your Effect into a custom Hook lets you precisely communicate your intent and how the data flows through it. + +For example, consider a `ShippingForm` component that displays two dropdowns: one shows the list of cities, and another shows the list of areas in the selected city. You might start with some code that looks like this: + +```js {3-16,20-35} +function ShippingForm({ country }) { + const [cities, setCities] = useState(null); + // This Effect fetches cities for a country + useEffect(() => { + let ignore = false; + fetch(`/api/cities?country=${country}`) + .then(response => response.json()) + .then(json => { + if (!ignore) { + setCities(json); + } + }); + return () => { + ignore = true; + }; + }, [country]); + + const [city, setCity] = useState(null); + const [areas, setAreas] = useState(null); + // This Effect fetches areas for the selected city + useEffect(() => { + if (city) { + let ignore = false; + fetch(`/api/areas?city=${city}`) + .then(response => response.json()) + .then(json => { + if (!ignore) { + setAreas(json); + } + }); + return () => { + ignore = true; + }; + } + }, [city]); + + // ... +``` + +Although this code is quite repetitive, [it's correct to keep these Effects separate from each other.](/learn/removing-effect-dependencies#is-your-effect-doing-several-unrelated-things) They synchronize two different things, so you shouldn't merge them into one Effect. Instead, you can simplify the `ShippingForm` component above by extracting the common logic between them into your own `useData` Hook: + +```js {2-18} +function useData(url) { + const [data, setData] = useState(null); + useEffect(() => { + if (url) { + let ignore = false; + fetch(url) + .then(response => response.json()) + .then(json => { + if (!ignore) { + setData(json); + } + }); + return () => { + ignore = true; + }; + } + }, [url]); + return data; +} +``` + +Now you can replace both Effects in the `ShippingForm` components with calls to `useData`: + +```js {2,4} +function ShippingForm({ country }) { + const cities = useData(`/api/cities?country=${country}`); + const [city, setCity] = useState(null); + const areas = useData(city ? `/api/areas?city=${city}` : null); + // ... +``` + +Extracting a custom Hook makes the data flow explicit. You feed the `url` in and you get the `data` out. By "hiding" your Effect inside `useData`, you also prevent someone working on the `ShippingForm` component from adding [unnecessary dependencies](/learn/removing-effect-dependencies) to it. Ideally, with time, most of your app's Effects will be in custom Hooks. + + + +Start by choosing your custom Hook's name. If you struggle to pick a clear name, it might mean that your Effect is too coupled to the rest of your component's logic, and is not yet ready to be extracted. + +Ideally, your custom Hook's name should be clear enough that even a person who doesn't write code often could have a good guess about what your custom Hook does, what it takes, and what it returns: + +* ✅ `useData(url)` +* ✅ `useImpressionLog(eventName, extraData)` +* ✅ `useChatRoom(options)` + +When you synchronize with an external system, your custom Hook name may be more technical and use jargon specific to that system. It's good as long as it would be clear to a person familiar with that system: + +* ✅ `useMediaQuery(query)` +* ✅ `useSocket(url)` +* ✅ `useIntersectionObserver(ref, options)` + +**Keep custom Hooks focused on concrete high-level use cases.** Avoid creating and using custom "lifecycle" Hooks that act as alternatives and convenience wrappers for the `useEffect` API itself: + +* 🔴 `useMount(fn)` +* 🔴 `useEffectOnce(fn)` +* 🔴 `useUpdateEffect(fn)` + +For example, this `useMount` Hook tries to ensure some code only runs "on mount": + +```js {4-5,14-15} +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + // 🔴 Avoid: using custom "lifecycle" Hooks + useMount(() => { + const connection = createConnection({ roomId, serverUrl }); + connection.connect(); + + post('/analytics/event', { eventName: 'visit_chat' }); + }); + // ... +} + +// 🔴 Avoid: creating custom "lifecycle" Hooks +function useMount(fn) { + useEffect(() => { + fn(); + }, []); // 🔴 React Hook useEffect has a missing dependency: 'fn' +} +``` + +**Custom "lifecycle" Hooks like `useMount` don't fit well into the React paradigm.** For example, this code example has a mistake (it doesn't "react" to `roomId` or `serverUrl` changes), but the linter won't warn you about it because the linter only checks direct `useEffect` calls. It won't know about your Hook. + +If you're writing an Effect, start by using the React API directly: + +```js +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + // ✅ Good: two raw Effects separated by purpose + + useEffect(() => { + const connection = createConnection({ serverUrl, roomId }); + connection.connect(); + return () => connection.disconnect(); + }, [serverUrl, roomId]); + + useEffect(() => { + post('/analytics/event', { eventName: 'visit_chat', roomId }); + }, [roomId]); + + // ... +} +``` + +Then, you can (but don't have to) extract custom Hooks for different high-level use cases: + +```js +function ChatRoom({ roomId }) { + const [serverUrl, setServerUrl] = useState('https://localhost:1234'); + + // ✅ Great: custom Hooks named after their purpose + useChatRoom({ serverUrl, roomId }); + useImpressionLog('visit_chat', { roomId }); + // ... +} +``` + +**A good custom Hook makes the calling code more declarative by constraining what it does.** For example, `useChatRoom(options)` can only connect to the chat room, while `useImpressionLog(eventName, extraData)` can only send an impression log to the analytics. If your custom Hook API doesn't constrain the use cases and is very abstract, in the long run it's likely to introduce more problems than it solves. + + + +### Custom Hooks help you migrate to better patterns {/*custom-hooks-help-you-migrate-to-better-patterns*/} + +Effects are an ["escape hatch"](/learn/escape-hatches): you use them when you need to "step outside React" and when there is no better built-in solution for your use case. With time, the React team's goal is to reduce the number of the Effects in your app to the minimum by providing more specific solutions to more specific problems. Wrapping Effects in custom Hooks makes it easier to upgrade your code when these solutions become available. Let's return to this example: + + + +```js +import { useOnlineStatus } from './useOnlineStatus.js'; + +function StatusBar() { + const isOnline = useOnlineStatus(); + return

You are {isOnline ? 'online' : 'offline'}!

; +} + +function SaveButton() { + const isOnline = useOnlineStatus(); + + function handleSaveClick() { + console.log('✅ Progress saved'); + } + + return ( + + ); +} + +export default function App() { + return ( + <> + + + + ); +} +``` + +```js useOnlineStatus.js active +import { useState, useEffect } from 'react'; + +export function useOnlineStatus() { + const [isOnline, setIsOnline] = useState(true); + useEffect(() => { + function handleOnline() { + setIsOnline(true); + } + function handleOffline() { + setIsOnline(false); + } + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + return isOnline; +} +``` + +
+ +In the above example, `useOnlineStatus` is implemented with a pair of [`useState`](/apis/react/useState) and [`useEffect`](/apis/react/useEffect). However, this isn't the best possible solution. There is a number of edge cases it doesn't consider. For example, it assumes that when the component mounts, `isOnline` is already `true`, but this may be wrong if the network already went offline. You can use the browser [`navigator.onLine`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine) API to check for that, but using it directly would break if you run your React app on the server to generate the initial HTML. In short, this code could be improved. + +Luckily, React 18 includes a dedicated API called [`useSyncExternalStore`](/apis/react/useSyncExternalStore) which takes care of all of these problems for you. Here is how your `useOnlineStatus` Hook, rewritten to take advantage of this new API: + + + +```js +import { useOnlineStatus } from './useOnlineStatus.js'; + +function StatusBar() { + const isOnline = useOnlineStatus(); + return

You are {isOnline ? 'online' : 'offline'}!

; +} + +function SaveButton() { + const isOnline = useOnlineStatus(); + + function handleSaveClick() { + console.log('✅ Progress saved'); + } + + return ( + + ); +} + +export default function App() { + return ( + <> + + + + ); +} +``` + +```js useOnlineStatus.js active +import { useSyncExternalStore } from 'react'; + +function subscribe(callback) { + window.addEventListener('online', callback); + window.addEventListener('offline', callback); + return () => { + window.removeEventListener('online', callback); + window.removeEventListener('offline', callback); + }; +} + +export function useOnlineStatus() { + return useSyncExternalStore( + subscribe, + () => navigator.onLine, // How to get the value on the client + () => true // How to get the value on the server + ); +} + +``` + +
+ +Notice how **you didn't need to change any of the components** to make this migration: + +```js {2,7} +function StatusBar() { + const isOnline = useOnlineStatus(); + // ... +} + +function SaveButton() { + const isOnline = useOnlineStatus(); + // ... +} +``` + +This is another reason for why wrapping Effects in custom Hooks is often beneficial: + +1. You make the data flow to and from your Effects very explicit. +2. You let your components focus on the intent rather than on the exact implementation of your Effects. +3. When React adds new features, you can remove those Effects without changing any of your components. + +Similar to a [design system,](https://uxdesign.cc/everything-you-need-to-know-about-design-systems-54b109851969) you might find it helpful to start extracting common idioms from your app's components into custom Hooks. This will keep your components' code focused on the intent, and let you avoid writing raw Effects very often. There are also many excellent custom Hooks maintained by the React community. + + + +We're still working out the details, but we expect that in the future, you'll write data fetching like this: + +```js {1,4,6} +import { use } from 'react'; // Not available yet! + +function ShippingForm({ country }) { + const cities = use(fetch(`/api/cities?country=${country}`)); + const [city, setCity] = useState(null); + const areas = city ? use(fetch(`/api/areas?city=${city}`)) : null; + // ... +``` + +If you use custom Hooks like `useData` above in your app, it will require fewer changes to migrate to the eventually recommended approach than if you write raw Effects in every component manually. However, the old approach will still work fine, so if you feel happy writing raw Effects, you can continue to do that. + + + + + +- Custom Hooks let you share logic between components. +- Custom Hooks must be named starting with `use` followed by a capital letter. +- Custom Hooks only share stateful logic, not state itself. +- You can pass reactive values from one Hook to another, and they stay up-to-date. +- All Hooks re-run every time your component re-renders. +- The code of your custom Hooks should be pure, like your component's code. +- Wrap event handlers received by custom Hooks into Event functions. +- Don't create custom Hooks like `useMount`. Keep their purpose specific. + + \ No newline at end of file diff --git a/beta/src/sidebarLearn.json b/beta/src/sidebarLearn.json index 799d4fc9..9e414ae8 100644 --- a/beta/src/sidebarLearn.json +++ b/beta/src/sidebarLearn.json @@ -182,6 +182,10 @@ { "title": "Removing Effect Dependencies", "path": "/learn/removing-effect-dependencies" + }, + { + "title": "Reusing Logic with Custom Hooks", + "path": "/learn/reusing-logic-with-custom-hooks" } ] }