Повторне використання логіки з користувацькими хуками

React постачається з декількома вбудованими хуками, такими як useState, useContext та useEffect. Іноді вам може знадобитися хук для якоїсь більш конкретної мети: наприклад, для отримання даних, відстеження того, чи є користувач в мережі, або для підключення до чату. Ви можете не знайти таких хуків у React, але ви можете створити власні хуки для потреб вашого застосунку.

  • Що таке користувацькі хуки та як написати свій власний
  • Як повторно використовувати логіку між компонентами
  • Як назвати та структурувати власні хуки
  • Коли і навіщо витягувати користувацькі хуки

Користувацькі хуки: Спільне використання логіки між компонентами

Уявіть, що ви розробляєте програму, яка значною мірою покладається на мережу (як і більшість програм). Ви хочете попередити користувача, якщо його мережеве з'єднання випадково розірвалося під час використання програми. Як би ви це зробили? Схоже, що вам знадобляться дві речі у вашому компоненті:

  1. Фрагмент стану, який відстежує, чи мережа онлайн.
  2. Ефект, який підписується на глобальні online та offline події і оновлює цей стан.

Це забезпечить синхронізацію вашого компонента зі станом мережі. Ви можете почати з чогось подібного:

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 <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

Спробуйте увімкнути та вимкнути мережу і помітити, як це StatusBar оновлюється у відповідь на ваші дії.

А тепер уявіть, що ви також хочете використати цю логіку в іншому компоненті. Ви хочете реалізувати кнопку Зберегти, яка стане неактивною і показуватиме "Перепідключення..." замість "Зберегти", коли мережу буде вимкнено.

Для початку ви можете скопіювати та вставити стан isOnline та ефект у SaveButton:

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 (
    <button disabled={!isOnline} onClick={handleSaveClick}>
      {isOnline ? 'Save progress' : 'Reconnecting...'}
    </button>
  );
}

Переконайтеся, що якщо вимкнути мережу, кнопка змінить свій вигляд.

Ці два компоненти працюють добре, але дублювання логіки між ними є прикрим. Складається враження, що хоча вони мають різний візуальний вигляд, ви хочете повторно використати логіку між ними.

Витяг власного хука з компонента

Уявіть на мить, що, подібно до useState та useEffect, існує вбудований useOnlineStatus хук. Тоді обидва ці компоненти можна було б спростити і прибрати дублювання між ними:

function StatusBar() {
  const isOnline = useOnlineStatus();
  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

function SaveButton() {
  const isOnline = useOnlineStatus();

  function handleSaveClick() {
    console.log('✅ Progress saved');
  }

  return (
    <button disabled={!isOnline} onClick={handleSaveClick}>
      {isOnline ? 'Save progress' : 'Reconnecting...'}
    </button>
  );
}

Хоча такого вбудованого хука немає, ви можете написати його самостійно. Оголосіть функцію з назвою useOnlineStatus і перенесіть до неї весь код, що дублюється, з компонентів, які ви написали раніше:

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;
}

У кінці функції поверніть isOnline. Це дозволить вашим компонентам прочитати це значення:

import { useOnlineStatus } from './useOnlineStatus.js';

function StatusBar() {
  const isOnline = useOnlineStatus();
  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

function SaveButton() {
  const isOnline = useOnlineStatus();

  function handleSaveClick() {
    console.log('✅ Progress saved');
  }

  return (
    <button disabled={!isOnline} onClick={handleSaveClick}>
      {isOnline ? 'Save progress' : 'Reconnecting...'}
    </button>
  );
}

export default function App() {
  return (
    <>
      <SaveButton />
      <StatusBar />
    </>
  );
}
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;
}

Переконайтеся, що увімкнення та вимкнення мережі оновлює обидва компоненти.

Тепер у ваших компонентах не так багато повторюваної логіки. Ще важливіше те, що код всередині них описує що вони хочуть зробити (використати статус онлайн!), а не як це зробити (підписавшись на події браузера).

Коли ви витягуєте логіку у користувацькі хуки, ви можете приховати складні деталі взаємодії із зовнішньою системою або API браузера. Код ваших компонентів виражає ваші наміри, а не реалізацію.

Назви хуків завжди починаються з use

React-застосунки будуються з компонентів. Компоненти збираються з хуків, як вбудованих, так і кастомних. Скоріш за все, ви часто будете використовувати кастомні хуки, створені іншими, але іноді ви можете написати хук самостійно!

Ви повинні дотримуватися таких правил іменування:

  1. Назви React-компонентів повинні починатися з великої літери, наприклад, StatusBar та SaveButton. React-компоненти також повинні повертати щось, що React вміє відображати, наприклад, фрагмент JSX.
  2. Назви хуків мають починатися з use з наступною великою літерою, наприклад useState (вбудований) або useOnlineStatus (користувацький, як раніше на сторінці). Хуки можуть повертати довільні значення.

Ця угода гарантує, що ви завжди можете подивитися на компонент і дізнатися, де може "ховатися" його стан, ефекти та інші можливості React. Наприклад, якщо ви бачите виклик функції getColor() у вашому компоненті, ви можете бути впевнені, що вона не може містити стан React, оскільки її назва не починається з use. Однак виклик функції на кшталт useOnlineStatus(), швидше за все, міститиме виклики інших хуків всередині!

Якщо ваш лінтер налаштований на React, він буде застосовувати цю угоду про імена. Перейдіть до пісочниці вище і перейменуйте useOnlineStatus на getOnlineStatus. Зверніть увагу, що лінтер більше не дозволить вам викликати useState або useEffect всередині нього. Тільки хуки та компоненти можуть викликати інші хуки!

Чи всі функції, що викликаються під час рендерингу, повинні починатися з префікса use?

Ні. Функції, які не викликають хуки, не повинні бути хуками.

Якщо ваша функція не викликає жодних хуків, не використовуйте префікс з . Замість цього запишіть її як звичайну функцію безпрефікса use. Наприклад, useSorted нижче не викликає хуки, тому замість нього викличте getSorted:

// 🔴 Avoid: A Hook that doesn't use Hooks
function useSorted(items) {
  return items.slice().sort();
}

// ✅ Good: A regular function that doesn't use Hooks
function getSorted(items) {
  return items.slice().sort();
}

Це гарантує, що ваш код може викликати цю регулярну функцію будь-де, включаючи умови:

function List({ items, shouldSort }) {
  let displayedItems = items;
  if (shouldSort) {
    // ✅ It's ok to call getSorted() conditionally because it's not a Hook
    displayedItems = getSorted(items);
  }
  // ...
}

Функції слід надати префікс use (і таким чином зробити її хуком), якщо вона використовує хоча б один хук всередині:

// ✅ Good: A Hook that uses other Hooks
function useAuth() {
  return useContext(Auth);
}

Технічно React не вимагає цього. В принципі, ви можете створити хук, який не викликає інші хуки. Це часто заплутує і обмежує можливості, тому краще уникати такого шаблону. Однак, існують рідкісні випадки, коли це може бути корисним. Наприклад, можливо, ваша функція не використовує хуки зараз, але ви плануєте додати до неї виклики хуків у майбутньому. Тоді має сенс назвати її з префіксом use:

// ✅ Good: A Hook that will likely use some other Hooks later
function useAuth() {
  // TODO: Replace with this line when authentication is implemented:
  // return useContext(Auth);
  return TEST_USER;
}

Тоді компоненти не зможуть викликати його умовно. Це стане важливим, коли ви будете додавати виклики хуків всередину. Якщо ви не плануєте використовувати хуки всередині нього (зараз або пізніше), не робіть його хуком.

Користувацькі хуки дозволяють ділитися вам логікою стану, але не самим станом

У попередньому прикладі, коли ви вмикали та вимикали мережу, обидва компоненти оновлювалися разом. Однак, помилково вважати, що вони використовують одну змінну стану isOnline. Подивіться на цей код:

function StatusBar() {
  const isOnline = useOnlineStatus();
  // ...
}

function SaveButton() {
  const isOnline = useOnlineStatus();
  // ...
}

Це працює так само, як і до того, як ви витягли дублікат:

function StatusBar() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    // ...
  }, []);
  // ...
}

function SaveButton() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    // ...
  }, []);
  // ...
}

Це дві абсолютно незалежні змінні стану та ефектів! Вони мають однакове значення одночасно, оскільки ви синхронізували їх з однаковим зовнішнім значенням (чи увімкнено мережу).

Щоб краще проілюструвати це, нам знадобиться інший приклад. Розглянемо такий компонент Form:

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 (
    <>
      <label>
        First name:
        <input value={firstName} onChange={handleFirstNameChange} />
      </label>
      <label>
        Last name:
        <input value={lastName} onChange={handleLastNameChange} />
      </label>
      <p><b>Good morning, {firstName} {lastName}.</b></p>
    </>
  );
}
label { display: block; }
input { margin-left: 10px; }

Для кожного поля форми існує певна логіка, що повторюється:

  1. Є частина стану (firstName та lastName).
  2. Є обробник змін (handleFirstNameChange і handleLastNameChange).
  3. Є шматок JSX, який визначає атрибути value та onChange для цього входу.

Ви можете витягти повторювану логіку у цей useFormInput користувацький хук:

import { useFormInput } from './useFormInput.js';

export default function Form() {
  const firstNameProps = useFormInput('Mary');
  const lastNameProps = useFormInput('Poppins');

  return (
    <>
      <label>
        First name:
        <input {...firstNameProps} />
      </label>
      <label>
        Last name:
        <input {...lastNameProps} />
      </label>
      <p><b>Good morning, {firstNameProps.value} {lastNameProps.value}.</b></p>
    </>
  );
}
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;
}
label { display: block; }
input { margin-left: 10px; }

Зверніть увагу, що у ньому оголошено лише одну змінну стану з назвою value.

Втім, компонент Form викликає useFormInput двічі:

function Form() {
  const firstNameProps = useFormInput('Mary');
  const lastNameProps = useFormInput('Poppins');
  // ...

Тому це працює як оголошення двох окремих змінних стану!

Користувацькі хуки дозволяють вам ділитися логікою стану, але не самим станом. Кожен виклик хука є повністю незалежним від будь-якого іншого виклику того самого хука. Ось чому дві наведені вище пісочниці є повністю еквівалентними. Якщо хочете, прокрутіть назад і порівняйте їх. Поведінка до і після вилучення користувацького хука ідентична.

Коли вам потрібно розділити сам стан між кількома компонентами, підніміть його і передайте вниз замість цього.

Передача реактивних значень між хуками

Код всередині ваших користувацьких хуків буде повторно запускатися під час кожного повторного рендерингу вашого компонента. Ось чому, як і компоненти, користувацькі хуки мають бути чистими.Сприймайте код користувацьких хуків як частину тіла вашого компонента!

Оскільки користувацькі хуки повторно рендеряться разом з вашим компонентом, вони завжди отримують найновіші пропси та стан. Щоб побачити, що це означає, розглянемо приклад чату. Змініть URL-адресу сервера або кімнату чату:

import { useState } from 'react';
import ChatRoom from './ChatRoom.js';

export default function App() {
  const [roomId, setRoomId] = useState('general');
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
      />
    </>
  );
}
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 (
    <>
      <label>
        Server URL:
        <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
      </label>
      <h1>Welcome to the {roomId} room!</h1>
    </>
  );
}
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;
    },
  };
}
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();
}
{
  "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"
  }
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }

Коли ви змінюєте serverUrl або roomId, Ефект "реагує" на ваші зміни і пересинхронізується. За повідомленнями у консолі ви можете визначити, що чат перепідключається щоразу, коли ви змінюєте залежності ефекту.

Тепер перемістіть код ефекту у спеціальний хук:

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]);
}

Це дозволяє вашому ChatRoom компоненту викликати ваш користувацький хук, не турбуючись про те, як він працює всередині:

export default function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useChatRoom({
    roomId: roomId,
    serverUrl: serverUrl
  });

  return (
    <>
      <label>
        Server URL:
        <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
      </label>
      <h1>Welcome to the {roomId} room!</h1>
    </>
  );
}

Це виглядає набагато простіше! (Але робить те саме).

Зверніть увагу, що логіка все ще реагує на зміни пропсів та станів. Спробуйте відредагувати URL-адресу сервера або вибраної кімнати:

import { useState } from 'react';
import ChatRoom from './ChatRoom.js';

export default function App() {
  const [roomId, setRoomId] = useState('general');
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
      />
    </>
  );
}
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 (
    <>
      <label>
        Server URL:
        <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
      </label>
      <h1>Welcome to the {roomId} room!</h1>
    </>
  );
}
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]);
}
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;
    },
  };
}
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();
}
{
  "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"
  }
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }

Зверніть увагу на те, як ви отримуєте значення, що повертається одним хуком:

export default function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useChatRoom({
    roomId: roomId,
    serverUrl: serverUrl
  });
  // ...

і передайте її як вхідні дані іншому хуку:

export default function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useChatRoom({
    roomId: roomId,
    serverUrl: serverUrl
  });
  // ...

Кожного разу, коли ваш ChatRoom компонент ChatRoom повторно рендерить, він передає останні roomId та serverUrl до вашого хука. Ось чому ваш ефект перепідключається до чату щоразу, коли їхні значення змінюються після рендерингу. (Якщо ви коли-небудь працювали з програмним забезпеченням для обробки аудіо або відео, подібне з'єднання хуків може нагадати вам ланцюжок візуальних або аудіо ефектів. Це виглядає так, ніби вихід useState "подається" на вхід useChatRoom.)

Передача обробників подій користувацьким хукам

Цей розділ описує експериментальний API, який ще не було випущено у стабільній версії React.

Коли ви почнете використовувати useChatRoom у більшій кількості компонентів, ви можете дозволити компонентам налаштовувати його поведінку. Наприклад, наразі логіка дій при отриманні повідомлення жорстко закодована всередині хука:

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]);
}

Припустимо, ви хочете перенести цю логіку назад до вашого компонента:

export default function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useChatRoom({
    roomId: roomId,
    serverUrl: serverUrl,
    onReceiveMessage(msg) {
      showNotification('New message: ' + msg);
    }
  });
  // ...

Щоб це працювало, змініть ваш користувацький хук так, щоб він приймав onReceiveMessage як одну з його іменованих опцій:

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
}

Це спрацює, але є ще одне покращення, яке можна зробити, коли ваш користувацький хук приймає обробники подій.

Додавання залежності до onReceiveMessage не є ідеальним варіантом, оскільки це призведе до перепідключення чату щоразу, коли компонент буде рендеритися. Обгорніть цей обробник події у подію ефекту, щоб вилучити його із залежностей:

import { useEffect, useEffectEvent } from 'react';
// ...

export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) {
  const onMessage = useEffectEvent(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
}

Тепер чат не буде перепідключатися щоразу, коли компонент ChatRoom рендерить повторно. Ось повністю робоча демонстрація передачі обробника події користувацькому хуку, з якою ви можете погратися:

import { useState } from 'react';
import ChatRoom from './ChatRoom.js';

export default function App() {
  const [roomId, setRoomId] = useState('general');
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
      />
    </>
  );
}
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 (
    <>
      <label>
        Server URL:
        <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
      </label>
      <h1>Welcome to the {roomId} room!</h1>
    </>
  );
}
import { useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';
import { createConnection } from './chat.js';

export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) {
  const onMessage = useEffectEvent(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]);
}
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;
    },
  };
}
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();
}
{
  "dependencies": {
    "react": "experimental",
    "react-dom": "experimental",
    "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"
  }
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }

Зверніть увагу, що вам більше не потрібно знати як працює useChatRoom, щоб використовувати його. Ви можете додати його до будь-якого іншого компонента, передати будь-які інші параметри, і він буде працювати так само. У цьому сила користувацьких хуків.

Коли використовувати користувацькі хуки

Вам не потрібно витягувати спеціальний хук для кожного маленького повторюваного біта коду. Декількох повторів достатньо. Наприклад, вилучення хука useFormInput для обгортання одного виклику useState, як це було раніше, можливо, не є необхідним.

Проте, коли ви пишете ефект, подумайте, чи не буде зрозуміліше обгорнути його у власний хук. Ефекти не повинні бути вам потрібні дуже часто, тому якщо ви пишете ефект, це означає, що вам потрібно "вийти за межі React", щоб синхронізуватися з якоюсь зовнішньою системою або зробити щось, для чого React не має вбудованого API. Обгорнувши це в кастомний хук, ви можете точно передати ваші наміри і те, як дані проходять через нього.

Наприклад, розглянемо компонент ShippingForm, який відображає два випадаючі списки: один показує список міст, а інший - список районів в обраному місті. Ви можете почати з такого коду:

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]);

  // ...

Хоча цей код досить повторюваний, правильно тримати ці ефекти окремо один від одного. Вони синхронізують дві різні речі, тому не варто об'єднувати їх в один ефект. Замість цього ви можете спростити компонент ShippingForm вище, витягнувши спільну логіку між ними у власний useData хук:

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;
}

Тепер ви можете замінити обидва ефекти у компонентах ShippingForm викликами до useData:

function ShippingForm({ country }) {
  const cities = useData(`/api/cities?country=${country}`);
  const [city, setCity] = useState(null);
  const areas = useData(city ? `/api/areas?city=${city}` : null);
  // ...

Витяг кастомного хука робить потік даних явним. Ви передаєте URL на вхід і отримуєте дані на виході. "Ховаючи" свій ефект всередині useData, ви також не даєте комусь, хто працює над компонентом ShippingForm, додати до нього непотрібні залежності. З часом більшість ефектів вашого застосунку будуть використовуватися у власних хуках.

Орієнтуйте власні хуки на конкретні високорівневі випадки використання

Почніть з вибору імені власного хука. Якщо вам важко підібрати чітку назву, це може означати, що ваш ефект надто пов'язаний з рештою логіки компонента і ще не готовий до вилучення.

В ідеалі, ім'я вашого кастомного хука має бути достатньо зрозумілим, щоб навіть людина, яка не часто пише код, могла здогадатися про те, що робить ваш кастомний хук, що він отримує і що повертає:

  • useData(url)
  • useImpressionLog(eventName, extraData)
  • useChatRoom(options)
  • useChatRoom(options)

Під час синхронізації із зовнішньою системою ваша власна назва хука може бути більш технічною і використовувати жаргон, характерний для цієї системи. Це добре, якщо воно буде зрозумілим для людини, знайомої з цією системою:

  • useMediaQuery(query)
  • useSocket(url)
  • useIntersectionObserver(ref, options)

Зосереджуйте власні хуки на конкретних високорівневих випадках використання. Уникайте створення та використання власних хуків "життєвого циклу", які діють як альтернативи та зручні обгортки для самого useEffect API:

  • 🔴 useMount(fn)
  • 🔴 useEffectOnce(fn)
  • 🔴 useUpdateEffect(fn)

Наприклад, цей useMount хук намагається забезпечити виконання деякого коду лише "на монтуванні":

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'
}

Кастомні хуки з "життєвим циклом", такі як useMount, не дуже добре вписуються в парадигму React. Наприклад, у цьому прикладі коду є помилка (він не "реагує" на зміни roomId або serverUrl), але лінтер не попередить вас про це, тому що лінтер перевіряє лише прямі виклики useEffect. Він не знатиме про ваш хук.

Якщо ви пишете ефект, почніть з безпосереднього використання React API:

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]);

  // ...
}

Далі, ви можете (але не зобов'язані) витягувати власні хуки для різних високорівневих випадків використання:

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  // ✅ Great: custom Hooks named after their purpose
  useChatRoom({ serverUrl, roomId });
  useImpressionLog('visit_chat', { roomId });
  // ...
}

Хороший користувацький хук робить викликаючий код більш декларативним, обмежуючи те, що він робить. Наприклад, useChatRoom(options) може лише з'єднуватися з чатом, тоді як useImpressionLog(eventName, extraData) може лише надсилати лог вражень до аналітики. Якщо ваш власний API хуків не обмежує варіанти використання і є дуже абстрактним, в довгостроковій перспективі він, швидше за все, створить більше проблем, ніж вирішить.

Кастомні хуки допоможуть вам перейти на кращі патерни

Ефекти - це "аварійний люк": ви використовуєте їх, коли вам потрібно "вийти за межі React" і коли немає кращого вбудованого рішення для вашого випадку використання. З часом команда React прагне зменшити кількість ефектів у вашому застосунку до мінімуму, надаючи більш специфічні рішення для більш специфічних проблем. Обгортання ваших ефектів у кастомні хуки полегшує оновлення коду, коли такі рішення стають доступними.

Повернімося до цього прикладу:

import { useOnlineStatus } from './useOnlineStatus.js';

function StatusBar() {
  const isOnline = useOnlineStatus();
  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

function SaveButton() {
  const isOnline = useOnlineStatus();

  function handleSaveClick() {
    console.log('✅ Progress saved');
  }

  return (
    <button disabled={!isOnline} onClick={handleSaveClick}>
      {isOnline ? 'Save progress' : 'Reconnecting...'}
    </button>
  );
}

export default function App() {
  return (
    <>
      <SaveButton />
      <StatusBar />
    </>
  );
}
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;
}

У наведеному вище прикладі useOnlineStatus реалізовано за допомогою пари useState та useEffect. Однак це не найкраще можливе рішення. Існує ряд граничних випадків, які він не враховує. Наприклад, передбачається, що на момент монтування компонента isOnline вже є true, але це може бути неправильно, якщо мережа вже вийшла з ладу. Ви можете скористатися API браузера navigator.onLine, щоб перевірити це, але використання його безпосередньо не спрацює на сервері для генерації початкового HTML. Коротше кажучи, цей код можна покращити.

На щастя, React 18 включає в себе спеціальний API під назвою useSyncExternalStore, який вирішує всі ці проблеми за вас. Ось як виглядає ваш хук useOnlineStatus, переписаний для використання переваг цього нового API:

import { useOnlineStatus } from './useOnlineStatus.js';

function StatusBar() {
  const isOnline = useOnlineStatus();
  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

function SaveButton() {
  const isOnline = useOnlineStatus();

  function handleSaveClick() {
    console.log('✅ Progress saved');
  }

  return (
    <button disabled={!isOnline} onClick={handleSaveClick}>
      {isOnline ? 'Save progress' : 'Reconnecting...'}
    </button>
  );
}

export default function App() {
  return (
    <>
      <SaveButton />
      <StatusBar />
    </>
  );
}
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
  );
}

Зверніть увагу, що вам не потрібно було змінювати жодного з компонентів для виконання цієї міграції:

function StatusBar() {
  const isOnline = useOnlineStatus();
  // ...
}

function SaveButton() {
  const isOnline = useOnlineStatus();
  // ...
}

Це ще одна причина, чому обгортання ефектів у власні хуки часто є корисним:

  1. Ви робите потік даних до та з ефектів дуже явним.
  2. Ви дозволяєте компонентам зосереджуватися на задумі, а не на точній реалізації ваших ефектів.
  3. Коли React додає нові можливості, ви можете видалити ці ефекти, не змінюючи жодного з ваших компонентів.

Подібно до системи проектування, вам може бути корисно почати витягувати загальні ідіоми з компонентів вашої програми у спеціальні хуки. Це дозволить зосередити код ваших компонентів на задумі і уникнути частого написання сирих ефектів. Багато чудових користувацьких хуків підтримуються спільнотою React.

Чи надасть React якесь вбудоване рішення для отримання даних?

Ми ще опрацьовуємо деталі, але очікуємо, що в майбутньому ви будете писати вибірку даних так:

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;
  // ...

Якщо ви використовуєте у своїй програмі кастомні хуки на кшталт useData вище, перехід на рекомендований підхід потребуватиме менше змін, ніж якщо ви будете писати сирі ефекти у кожному компоненті вручну. Втім, старий підхід все одно працюватиме чудово, тому якщо вам подобається писати сирі ефекти, ви можете продовжувати це робити.

Існує більше ніж один спосіб зробити це

Припустимо, ви хочете реалізувати анімацію згладжування з нуля за допомогою API браузера requestAnimationFrame. Ви можете почати з ефекту, який створює цикл анімації. Під час кожного кадру анімації ви можете змінювати непрозорість DOM-вузла, який ви утримуєте у рефі, доки вона не досягне 1. Ваш код може виглядати так:

import { useState, useEffect, useRef } from 'react';

function Welcome() {
  const ref = useRef(null);

  useEffect(() => {
    const duration = 1000;
    const node = ref.current;

    let startTime = performance.now();
    let frameId = null;

    function onFrame(now) {
      const timePassed = now - startTime;
      const progress = Math.min(timePassed / duration, 1);
      onProgress(progress);
      if (progress < 1) {
        // We still have more frames to paint
        frameId = requestAnimationFrame(onFrame);
      }
    }

    function onProgress(progress) {
      node.style.opacity = progress;
    }

    function start() {
      onProgress(0);
      startTime = performance.now();
      frameId = requestAnimationFrame(onFrame);
    }

    function stop() {
      cancelAnimationFrame(frameId);
      startTime = null;
      frameId = null;
    }

    start();
    return () => stop();
  }, []);

  return (
    <h1 className="welcome" ref={ref}>
      Welcome
    </h1>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Remove' : 'Show'}
      </button>
      <hr />
      {show && <Welcome />}
    </>
  );
}
label, button { display: block; margin-bottom: 20px; }
html, body { min-height: 300px; }
.welcome {
  opacity: 0;
  color: white;
  padding: 50px;
  text-align: center;
  font-size: 50px;
  background-image: radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%);
}

Щоб зробити компонент більш читабельним, ви можете витягти логіку у спеціальний хук useFadeIn:

import { useState, useEffect, useRef } from 'react';
import { useFadeIn } from './useFadeIn.js';

function Welcome() {
  const ref = useRef(null);

  useFadeIn(ref, 1000);

  return (
    <h1 className="welcome" ref={ref}>
      Welcome
    </h1>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Remove' : 'Show'}
      </button>
      <hr />
      {show && <Welcome />}
    </>
  );
}
import { useEffect } from 'react';

export function useFadeIn(ref, duration) {
  useEffect(() => {
    const node = ref.current;

    let startTime = performance.now();
    let frameId = null;

    function onFrame(now) {
      const timePassed = now - startTime;
      const progress = Math.min(timePassed / duration, 1);
      onProgress(progress);
      if (progress < 1) {
        // We still have more frames to paint
        frameId = requestAnimationFrame(onFrame);
      }
    }

    function onProgress(progress) {
      node.style.opacity = progress;
    }

    function start() {
      onProgress(0);
      startTime = performance.now();
      frameId = requestAnimationFrame(onFrame);
    }

    function stop() {
      cancelAnimationFrame(frameId);
      startTime = null;
      frameId = null;
    }

    start();
    return () => stop();
  }, [ref, duration]);
}
label, button { display: block; margin-bottom: 20px; }
html, body { min-height: 300px; }
.welcome {
  opacity: 0;
  color: white;
  padding: 50px;
  text-align: center;
  font-size: 50px;
  background-image: radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%);
}

Ви можете залишити код useFadeIn як є, але ви також можете його переробити. Наприклад, ви можете витягти логіку налаштування циклу анімації з useFadeIn у спеціальний useAnimationLoop хук:

import { useState, useEffect, useRef } from 'react';
import { useFadeIn } from './useFadeIn.js';

function Welcome() {
  const ref = useRef(null);

  useFadeIn(ref, 1000);

  return (
    <h1 className="welcome" ref={ref}>
      Welcome
    </h1>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Remove' : 'Show'}
      </button>
      <hr />
      {show && <Welcome />}
    </>
  );
}
import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';

export function useFadeIn(ref, duration) {
  const [isRunning, setIsRunning] = useState(true);

  useAnimationLoop(isRunning, (timePassed) => {
    const progress = Math.min(timePassed / duration, 1);
    ref.current.style.opacity = progress;
    if (progress === 1) {
      setIsRunning(false);
    }
  });
}

function useAnimationLoop(isRunning, drawFrame) {
  const onFrame = useEffectEvent(drawFrame);

  useEffect(() => {
    if (!isRunning) {
      return;
    }

    const startTime = performance.now();
    let frameId = null;

    function tick(now) {
      const timePassed = now - startTime;
      onFrame(timePassed);
      frameId = requestAnimationFrame(tick);
    }

    tick();
    return () => cancelAnimationFrame(frameId);
  }, [isRunning]);
}
label, button { display: block; margin-bottom: 20px; }
html, body { min-height: 300px; }
.welcome {
  opacity: 0;
  color: white;
  padding: 50px;
  text-align: center;
  font-size: 50px;
  background-image: radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%);
}
{
  "dependencies": {
    "react": "experimental",
    "react-dom": "experimental",
    "react-scripts": "latest"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }
}

Втім, ви не повинні цього робити. Як і у випадку зі звичайними функціями, ви самі вирішуєте, де провести межі між різними частинами вашого коду. Ви також можете застосувати зовсім інший підхід. Замість того, щоб зберігати логіку в ефекті, ви можете перемістити більшу частину імперативної логіки всередину JavaScript класу:

import { useState, useEffect, useRef } from 'react';
import { useFadeIn } from './useFadeIn.js';

function Welcome() {
  const ref = useRef(null);

  useFadeIn(ref, 1000);

  return (
    <h1 className="welcome" ref={ref}>
      Welcome
    </h1>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Remove' : 'Show'}
      </button>
      <hr />
      {show && <Welcome />}
    </>
  );
}
import { useState, useEffect } from 'react';
import { FadeInAnimation } from './animation.js';

export function useFadeIn(ref, duration) {
  useEffect(() => {
    const animation = new FadeInAnimation(ref.current);
    animation.start(duration);
    return () => {
      animation.stop();
    };
  }, [ref, duration]);
}
export class FadeInAnimation {
  constructor(node) {
    this.node = node;
  }
  start(duration) {
    this.duration = duration;
    this.onProgress(0);
    this.startTime = performance.now();
    this.frameId = requestAnimationFrame(() => this.onFrame());
  }
  onFrame() {
    const timePassed = performance.now() - this.startTime;
    const progress = Math.min(timePassed / this.duration, 1);
    this.onProgress(progress);
    if (progress === 1) {
      this.stop();
    } else {
      // We still have more frames to paint
      this.frameId = requestAnimationFrame(() => this.onFrame());
    }
  }
  onProgress(progress) {
    this.node.style.opacity = progress;
  }
  stop() {
    cancelAnimationFrame(this.frameId);
    this.startTime = null;
    this.frameId = null;
    this.duration = 0;
  }
}
label, button { display: block; margin-bottom: 20px; }
html, body { min-height: 300px; }
.welcome {
  opacity: 0;
  color: white;
  padding: 50px;
  text-align: center;
  font-size: 50px;
  background-image: radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%);
}

Ефекти дозволяють підключати React до зовнішніх систем. Чим більше координації між ефектами потрібно (наприклад, для створення ланцюжка з декількох анімацій), тим більше сенсу витягти цю логіку з ефектів і хуків повністю, як у пісочниці вище. Тоді витягнутий код стає "зовнішньою системою". Це дозволяє вашим ефектам залишатися простими, оскільки їм потрібно надсилати повідомлення лише до системи, яку ви винесли за межі React.

У наведених вище прикладах припускається, що логіку переходу до наступного відео потрібно писати на JavaScript. Однак, цю конкретну анімацію згладжування і простіше, і набагато ефективніше реалізувати за допомогою простої CSS-анімації:

.
import { useState, useEffect, useRef } from 'react';
import './welcome.css';

function Welcome() {
  return (
    <h1 className="welcome">
      Welcome
    </h1>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Remove' : 'Show'}
      </button>
      <hr />
      {show && <Welcome />}
    </>
  );
}
label, button { display: block; margin-bottom: 20px; }
html, body { min-height: 300px; }
.welcome {
  color: white;
  padding: 50px;
  text-align: center;
  font-size: 50px;
  background-image: radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%);

  animation: fadeIn 1000ms;
}

@keyframes fadeIn {
  0% { opacity: 0; }
  100% { opacity: 1; }
}

Іноді вам навіть не потрібен хук!

  • Користувацькі хуки дозволяють розділити логіку між компонентами.
  • Користувацькі хуки слід називати, починаючи з use з великою літерою.
  • Користувацькі хуки передають лише логіку стану, але не сам стан.
  • Ви можете передавати реактивні значення від одного хука до іншого, і вони залишаться актуальними
  • Усі хуки повторно запускаються щоразу, коли ваш компонент рендериться повторно.
  • Код ваших користувацьких хуків має бути чистим, як і код вашого компонента.
  • Обгорнути обробники подій, отримані користувацькими хуками, у події ефекту
  • Не створюйте кастомні хуки на кшталт useMount. Зберігайте їхнє призначення специфічним.
  • Ви самі вирішуєте, як і де вибрати межі вашого коду.

Витягнути гачок useCounter Hook

Цей компонент використовує змінну стану та ефект для відображення числа, яке збільшується щосекунди. Витягніть цю логіку у спеціальний хук з назвою useCounter. Ваша мета полягає у тому, щоб реалізація компонента Counter виглядала саме так:

export default function Counter() {
  const count = useCounter();
  return <h1>Seconds passed: {count}</h1>;
}

Вам потрібно написати свій хук у useCounter.js та імпортувати його у файл Counter.js.

import { useState, useEffect } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return <h1>Seconds passed: {count}</h1>;
}
// Write your custom Hook in this file!

Ваш код має виглядати так:

import { useCounter } from './useCounter.js';

export default function Counter() {
  const count = useCounter();
  return <h1>Seconds passed: {count}</h1>;
}
import { useState, useEffect } from 'react';

export function useCounter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return count;
}

Зверніть увагу, що App.js більше не потрібно імпортувати useState або useEffect.

Зробіть затримку лічильника налаштовуваною

У цьому прикладі є змінна стану delay, керована повзунком, але її значення не використовується. Передайте значення delay у ваш користувацький хук useCounter і змініть хук useCounter, щоб він використовував передане значення delay замість жорсткого кодування 1000 ms.

import { useState } from 'react';
import { useCounter } from './useCounter.js';

export default function Counter() {
  const [delay, setDelay] = useState(1000);
  const count = useCounter();
  return (
    <>
      <label>
        Tick duration: {delay} ms
        <br />
        <input
          type="range"
          value={delay}
          min="10"
          max="2000"
          onChange={e => setDelay(Number(e.target.value))}
        />
      </label>
      <hr />
      <h1>Ticks: {count}</h1>
    </>
  );
}
import { useState, useEffect } from 'react';

export function useCounter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return count;
}

Передайте затримку у ваш хук за допомогою useCounter(delay). Потім всередині хука використовуйте delay замість жорстко закодованого значення 1000. Вам потрібно додати delay до залежностей ефекту. Це гарантує, що зміна delay призведе до скидання інтервалу.

import { useState } from 'react';
import { useCounter } from './useCounter.js';

export default function Counter() {
  const [delay, setDelay] = useState(1000);
  const count = useCounter(delay);
  return (
    <>
      <label>
        Tick duration: {delay} ms
        <br />
        <input
          type="range"
          value={delay}
          min="10"
          max="2000"
          onChange={e => setDelay(Number(e.target.value))}
        />
      </label>
      <hr />
      <h1>Ticks: {count}</h1>
    </>
  );
}
import { useState, useEffect } from 'react';

export function useCounter(delay) {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, delay);
    return () => clearInterval(id);
  }, [delay]);
  return count;
}

Витягти useInterval з useCounter

Наразі ваш useCounter хук виконує дві речі. Він встановлює інтервал, а також збільшує змінну стану на кожному кроці інтервалу. Виділіть логіку, яка встановлює інтервал, в окремий хук з назвою useInterval. Він повинен приймати два аргументи: зворотний виклик onTick і затримку . Після цих змін ваша реалізація useCounter має виглядати так:

export function useCounter(delay) {
  const [count, setCount] = useState(0);
  useInterval(() => {
    setCount(c => c + 1);
  }, delay);
  return count;
}

Запишіть useInterval у файл useInterval.js та імпортуйте його у файл useCounter.js.

import { useState } from 'react';
import { useCounter } from './useCounter.js';

export default function Counter() {
  const count = useCounter(1000);
  return <h1>Seconds passed: {count}</h1>;
}
import { useState, useEffect } from 'react';

export function useCounter(delay) {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, delay);
    return () => clearInterval(id);
  }, [delay]);
  return count;
}
// Write your Hook here!

Логіка всередині useInterval має встановити та очистити інтервал. Більше нічого не потрібно робити.

import { useCounter } from './useCounter.js';

export default function Counter() {
  const count = useCounter(1000);
  return <h1>Seconds passed: {count}</h1>;
}
import { useState } from 'react';
import { useInterval } from './useInterval.js';

export function useCounter(delay) {
  const [count, setCount] = useState(0);
  useInterval(() => {
    setCount(c => c + 1);
  }, delay);
  return count;
}
import { useEffect } from 'react';

export function useInterval(onTick, delay) {
  useEffect(() => {
    const id = setInterval(onTick, delay);
    return () => clearInterval(id);
  }, [onTick, delay]);
}

Зауважте, що з цим розв'язком є невелика проблема, яку ви вирішите у наступній задачі.

Виправити інтервал скидання

У цьому прикладі є два окремих інтервали.

Компонент App викликає useCounter, який викликає useInterval для оновлення лічильника кожної секунди. Але компонент App також викликає useInterval для випадкового оновлення кольору фону сторінки кожні дві секунди.

З якоїсь причини колбек, який оновлює фон сторінки, ніколи не виконується. Додайте кілька логів всередині useInterval:

useEffect(() => {
    console.log('✅ Setting up an interval with delay ', delay)
    const id = setInterval(onTick, delay);
    return () => {
      console.log('❌ Clearing an interval with delay ', delay)
      clearInterval(id);
    };
  }, [onTick, delay]);

Чи збігаються дані журналу з тим, що ви очікували? Якщо деякі з ваших ефектів повторно синхронізуються без потреби, чи можете ви здогадатися, яка залежність спричиняє це? Чи можна якось видалити цю залежність з вашого ефекту?

Після виправлення проблеми слід очікувати, що фон сторінки оновлюватиметься кожні дві секунди.

Схоже, що ваш useInterval хук приймає слухач події як аргумент. Чи можете ви придумати спосіб обгорнути цей слухач події так, щоб він не був залежним від вашого ефекту?

{
  "dependencies": {
    "react": "experimental",
    "react-dom": "experimental",
    "react-scripts": "latest"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }
}
import { useCounter } from './useCounter.js';
import { useInterval } from './useInterval.js';

export default function Counter() {
  const count = useCounter(1000);

  useInterval(() => {
    const randomColor = `hsla(${Math.random() * 360}, 100%, 50%, 0.2)`;
    document.body.style.backgroundColor = randomColor;
  }, 2000);

  return <h1>Seconds passed: {count}</h1>;
}
import { useState } from 'react';
import { useInterval } from './useInterval.js';

export function useCounter(delay) {
  const [count, setCount] = useState(0);
  useInterval(() => {
    setCount(c => c + 1);
  }, delay);
  return count;
}
import { useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';

export function useInterval(onTick, delay) {
  useEffect(() => {
    const id = setInterval(onTick, delay);
    return () => {
      clearInterval(id);
    };
  }, [onTick, delay]);
}

Усередині useInterval обгорніть зворотний виклик у подію ефекту, як ви зробили раніше на цій сторінці.

Це дозволить вам вилучити onTick із залежностей вашого ефекту. Ефект не буде повторно синхронізуватися при кожному повторному рендерингу компонента, тому інтервал зміни кольору фону сторінки не буде скидатися щосекунди до того, як він встигне спрацювати.

З цією зміною обидва інтервали працюють як очікувалося і не заважають один одному:

{
  "dependencies": {
    "react": "experimental",
    "react-dom": "experimental",
    "react-scripts": "latest"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }
}
import { useCounter } from './useCounter.js';
import { useInterval } from './useInterval.js';

export default function Counter() {
  const count = useCounter(1000);

  useInterval(() => {
    const randomColor = `hsla(${Math.random() * 360}, 100%, 50%, 0.2)`;
    document.body.style.backgroundColor = randomColor;
  }, 2000);

  return <h1>Seconds passed: {count}</h1>;
}
import { useState } from 'react';
import { useInterval } from './useInterval.js';

export function useCounter(delay) {
  const [count, setCount] = useState(0);
  useInterval(() => {
    setCount(c => c + 1);
  }, delay);
  return count;
}
import { useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';

export function useInterval(callback, delay) {
  const onTick = useEffectEvent(callback);
  useEffect(() => {
    const id = setInterval(onTick, delay);
    return () => clearInterval(id);
  }, [delay]);
}

Реалізувати хиткий рух

У цьому прикладі хук usePointerPosition() відстежує поточну позицію вказівника. Спробуйте навести курсор або палець на область попереднього перегляду і побачити, як червона крапка слідує за вашим рухом. Її положення зберігається у змінній pos1.

Насправді, на екрані з'являється п'ять (!) різних червоних крапок. Ви їх не бачите, тому що наразі всі вони відображаються в одній позиції. Це те, що вам потрібно виправити. Натомість ви хочете реалізувати "шаховий" рух: кожна точка повинна "повторювати" шлях попередньої. Наприклад, якщо ви швидко пересуваєте курсор, перша крапка повинна слідувати за ним негайно, друга крапка повинна слідувати за першою з невеликою затримкою, третя крапка повинна слідувати за другою і т.д.

Вам потрібно реалізувати користувацький хук useDelayedValue. Його поточна реалізація повертає значення , надане йому. Натомість ви хочете повернути значення, отримане з delay мілісекунд тому. Для цього вам може знадобитися деякий стан та ефект.

Після виконання useDelayedValue ви повинні побачити, як точки рухаються одна за одною.

Вам потрібно зберегти delayedValue як змінну стану всередині вашого хука. Коли значення змінюється, вам слід запустити ефект. Цей ефект має оновити delayedValue після затримки. Можливо, вам буде корисно викликати setTimeout.

Чи потребує цей ефект очищення? Так чи ні?

import { usePointerPosition } from './usePointerPosition.js';

function useDelayedValue(value, delay) {
  // TODO: Implement this Hook
  return value;
}

export default function Canvas() {
  const pos1 = usePointerPosition();
  const pos2 = useDelayedValue(pos1, 100);
  const pos3 = useDelayedValue(pos2, 200);
  const pos4 = useDelayedValue(pos3, 100);
  const pos5 = useDelayedValue(pos3, 50);
  return (
    <>
      <Dot position={pos1} opacity={1} />
      <Dot position={pos2} opacity={0.8} />
      <Dot position={pos3} opacity={0.6} />
      <Dot position={pos4} opacity={0.4} />
      <Dot position={pos5} opacity={0.2} />
    </>
  );
}

function Dot({ position, opacity }) {
  return (
    <div style={{
      position: 'absolute',
      backgroundColor: 'pink',
      borderRadius: '50%',
      opacity,
      transform: `translate(${position.x}px, ${position.y}px)`,
      pointerEvents: 'none',
      left: -20,
      top: -20,
      width: 40,
      height: 40,
    }} />
  );
}
import { useState, useEffect } from 'react';

export function usePointerPosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  useEffect(() => {
    function handleMove(e) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
  }, []);
  return position;
}
body { min-height: 300px; }

Це робоча версія. Ви зберігаєте delayedValue як змінну стану. Коли значення оновлюється, ваш ефект планує таймаут для оновлення delayedValue. Ось чому delayedValue завжди "відстає" від фактичного значення.

import { useState, useEffect } from 'react';
import { usePointerPosition } from './usePointerPosition.js';

function useDelayedValue(value, delay) {
  const [delayedValue, setDelayedValue] = useState(value);

  useEffect(() => {
    setTimeout(() => {
      setDelayedValue(value);
    }, delay);
  }, [value, delay]);

  return delayedValue;
}

export default function Canvas() {
  const pos1 = usePointerPosition();
  const pos2 = useDelayedValue(pos1, 100);
  const pos3 = useDelayedValue(pos2, 200);
  const pos4 = useDelayedValue(pos3, 100);
  const pos5 = useDelayedValue(pos3, 50);
  return (
    <>
      <Dot position={pos1} opacity={1} />
      <Dot position={pos2} opacity={0.8} />
      <Dot position={pos3} opacity={0.6} />
      <Dot position={pos4} opacity={0.4} />
      <Dot position={pos5} opacity={0.2} />
    </>
  );
}

function Dot({ position, opacity }) {
  return (
    <div style={{
      position: 'absolute',
      backgroundColor: 'pink',
      borderRadius: '50%',
      opacity,
      transform: `translate(${position.x}px, ${position.y}px)`,
      pointerEvents: 'none',
      left: -20,
      top: -20,
      width: 40,
      height: 40,
    }} />
  );
}
import { useState, useEffect } from 'react';

export function usePointerPosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  useEffect(() => {
    function handleMove(e) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
  }, []);
  return position;
}
body { min-height: 300px; }

Зауважте, що цей ефект не потребує очищення. Якщо б ви викликали clearTimeout у функції очищення, то кожного разу при зміні значення скидали б вже запланований таймаут. Щоб рух не припинявся, потрібно, щоб усі таймаути спрацьовували.