Урок 18: Кастомные хуки (переиспользование логики в React)

Мы с вами уже прошли большой путь, изучили основы, состояние, эффекты и контекст. Вы стали настоящими повелителями компонентов! Но по мере роста вашего приложения вы начнете замечать одну повторяющуюся проблему, разный код в разных компонентах начинает делать одно и то же. Логика, например, загрузки данных из API или подписки на события, кочует из одного файла в другой, создавая дублирование. Сегодня мы научимся решать эту проблему эффективно с помощью Кастомных хуков.

Кастомные хуки это не новшество в React, а скорее следствие его философии. Это ваша возможность создавать собственные инструменты, свои абстракции, которые делают код чище, понятнее и легко переиспользуемым. Представьте, что у вас есть набор отверток (встроенные хуки вроде useState и useEffect), а кастомные хуки это ваша собственная, идеально сбалансированная дрель, собранная из этих отверток для решения конкретной, часто повторяющейся задачи. Давайте же соберем свою первую дрель -)

Для чего нужен Кастомный хук?

Давайте начнем с самого главного, с определения. Кастомный хук это JavaScript-функция, имя которой начинается с приставки use и которая может вызывать другие хуки. Вот и все! В этой простоте и заключается вся их мощь. Вы не создаете новый вид хуков с нуля, вы просто комбинируете существующие встроенные хуки (useStateuseEffectuseContext и т.д.) в новую, логически завершенную функцию.

А зачем это нужно? Представьте себе сценарий. У вас в приложении есть два компонента: UserProfile и Dashboard. Оба должны загружать данные о пользователе с сервера. Без кастомных хуков ваш код будет выглядеть примерно так:

UserProfile.jsx:

jsx
import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error('Ошибка загрузки');
        const userData = await response.json();
        setUser(userData);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();
  }, [userId]);

  if (loading) return <div>Загрузка профиля...</div>;
  if (error) return <div>Ошибка: {error}</div>;
  if (!user) return null;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

export default UserProfile;

А теперь посмотрите на Dashboard.jsx. Он будет содержать практически идентичный блок кода с useState и useEffect. Это нарушает принцип DRY (Don’t Repeat Yourself — «Не повторяйся»). Что если нужно изменить логику обработки ошибок? Придется лезть в каждый компонент и вносить правки. Это не только неудобно, но и чревато ошибками.

Кастомный хук позволяет вынести всю эту общую логику в одно место. Мы создадим хук useUserData, который будет принимать userId и возвращать нам объект { user, loading, error }. Тогда наши компоненты станут невероятно простыми и чистыми. Они будут заниматься тем, для чего и созданы рендерингом UI, а вся «грязная» работа с данными останется в хуке.

Правила создания кастомных хуков

Прежде чем мы перейдем к практике, давайте усвоим несколько простых, но обязательных правил. React полагается на эти правила, чтобы правильно работать с хуками.

  1. Имя должно начинаться с use. Это не просто соглашение, это требование. React полагается на эту конвенцию, чтобы автоматически применять к вашему коду правила хуков. useDatauseToggleuseLocalStorage,  все это валидные имена. getData или fetchInfo нет.

  2. Внутри кастомного хука вы можете вызывать другие хуки. Вы можете использовать useState для хранения состояния, useEffect для побочных эффектов, useContext для доступа к контексту и даже другие кастомные хуки! Это то, что придает им силу.

  3. Вызывайте хуки только на верхнем уровне. Не кладите их в условия, циклы или вложенные функции. Это правило действует для всех хуков, включая кастомные.

  4. Кастомный хук это функция. Она может принимать аргументы и возвращать любые значения: примитивы, объекты, массивы, другие функции. Все, что вам нужно.

Следуя этим правилам, вы создаете хуки, которые React понимает и с которыми может корректно работать.

Создаем наш первый кастомный хук: useToggle

Давайте начнем с чего-то простого, чтобы «прочувствовать» механизм. Очень частая задача в UI, это переключение булева значения: открыть/закрыть модальное окно, показать/скрыть пароль, активировать/деактивировать режим редактирования. Создадим для этого универсальный хук useToggle.

Обычно в компоненте вы бы делали так:

jsx
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(prev => !prev);

Давайте вынесем эту логику в отдельный хук. Создадим файл useToggle.js:

jsx
// useToggle.js
import { useState, useCallback } from 'react';

// Кастомный хук useToggle
// Принимает необязательное начальное значение (по умолчанию false)
// Возвращает массив [value, toggleFunction, setValue]
function useToggle(initialValue = false) {
  // Состояние для хранения текущего значения
  const [value, setValue] = useState(initialValue);

  // Функция для переключения значения. useCallback мемоизирует функцию, чтобы она не менялась при каждом рендере.
  const toggle = useCallback(() => {
    setValue(prevValue => !prevValue);
  }, []); // Зависимостей нет, функция стабильна

  // Возвращаем значение и функцию для переключения.
  // Также можно вернуть и setValue для прямого управления, если это нужно.
  return [value, toggle, setValue];
}

export default useToggle;

Теперь давайте посмотрим, как волшебным образом упростится наш компонент:

jsx
// ToggleComponent.jsx
import React from 'react';
import useToggle from './hooks/useToggle'; // Импортируем наш хук

function ToggleComponent() {
  // Используем наш хук. Он возвращает текущее состояние и функцию-переключатель.
  const [isVisible, toggleVisibility] = useToggle(false);

  return (
    <div>
      <button onClick={toggleVisibility}>
        {isVisible ? 'Скрыть' : 'Показать'}
      </button>
      {isVisible && <p>Секретное сообщение!</p>}
    </div>
  );
}

export default ToggleComponent;

Видите? Вся логика управления состоянием инкапсулирована в хуке. Компонент теперь стал декларативным. Он говорит: «У меня есть какое-то состояние видимости и функция для его изменения и я использую их здесь». Он больше не знает как это состояние меняется. Это разделение ответственности, ключ к чистому и поддерживаемому коду.

Практическая задача 1:
Создайте компонент для формы входа, где есть чекбокс «Запомнить меня». Реализуйте логику этого чекбокса с помощью хука useToggle.

Кастомный хук для загрузки данных: useFetch

Теперь давайте решим ту самую масштабную проблему, с которой мы начали, дублирование логики загрузки данных. Создадим мощный и переиспользуемый хук useFetch.

Мы хотим, чтобы наш хук:

  1. Принимал URL для запроса.

  2. Сам управлял состояниями dataloading и error.

  3. Выполнял запрос при монтировании и при изменении URL.

  4. Возвращал нам эти состояния, чтобы компонент мог их отобразить.

Создадим файл useFetch.js:

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

function useFetch(url) {
  // Определяем состояния
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Функция для выполнения запроса
    const fetchData = async () => {
      setLoading(true);
      setError(null); // Сбрасываем ошибку перед новым запросом

      try {
        const response = await fetch(url);

        // Проверяем, что ответ успешный (статус 200-299)
        if (!response.ok) {
          throw new Error(`Ошибка HTTP: ${response.status}`);
        }

        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err.message);
      } finally {
        // finally выполнится в любом случае, была ошибка или нет.
        setLoading(false);
      }
    };

    // Вызываем функцию запроса
    fetchData();
  }, [url]); // Эффект зависит от url. При смене url запрос выполнится заново.

  // Возвращаем состояния в виде объекта (чаще используется) или массива.
  return { data, loading, error };
}

export default useFetch;

Давайте перепишем наши громоздкие компоненты UserProfile и Dashboard с использованием нового хука.

UserProfile.jsx:

jsx
import React from 'react';
import useFetch from './hooks/useFetch';

function UserProfile({ userId }) {
  // Используем наш хук. Вся логика внутри него!
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <div>Загрузка профиля...</div>;
  if (error) return <div>Ошибка: {error}</div>;
  if (!user) return null;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

export default UserProfile;

Dashboard.jsx:

jsx
import React from 'react';
import useFetch from './hooks/useFetch';

function Dashboard() {
  // Тот же хук, но для другого URL!
  const { data: stats, loading, error } = useFetch('/api/dashboard/stats');

  if (loading) return <div>Загружаем статистику...</div>;
  if (error) return <div>Ошибка загрузки статистики: {error}</div>;

  return (
    <div>
      <h1>Дашборд</h1>
      <p>Количество посещений: {stats?.visits}</p>
    </div>
  );
}

export default Dashboard;

Вот оно! Мы устранили дублирование кода. Теперь, если мы захотим улучшить логику загрузки (добавить кэширование, повторы запроса при ошибке и т.д.), нам нужно будет изменить всего один файл useFetch.js. Все компоненты, использующие этот хук, автоматически получат новые возможности. Это и есть сила абстракции!

Практическая задача 2:
Улучшите хук useFetch, добавив возможность отмены запроса. Подсказка: используйте AbortController внутри useEffect. Функция очистки эффекта должна вызывать abort().

Кастомный хук для работы с локальным хранилищем (useLocalStorage)

Еще один частый кейс, это синхронизация состояния React с localStorage. Давайте создадим хук useLocalStorage, который будет вести себя почти как useState, но сохранять значение в хранилище браузера и считывать его оттуда при инициализации.

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

function useLocalStorage(key, initialValue) {
  // Получаем значение из localStorage при инициализации
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      // Если значение найдено в localStorage, парсим его, иначе используем initialValue
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      // В случае ошибки тоже используем initialValue
      console.error(`Ошибка чтения ключа "${key}" из localStorage:`, error);
      return initialValue;
    }
  });

  // Обертка над setStoredValue, которая также сохраняет значение в localStorage
  const setValue = (value) => {
    try {
      // Разрешаем value быть функцией, как в стандартном useState
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      // Сохраняем в localStorage
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(`Ошибка записи ключа "${key}" в localStorage:`, error);
    }
  };

  return [storedValue, setValue];
}

export default useLocalStorage;

Использование в компоненте:

jsx
// SettingsComponent.jsx
import React from 'react';
import useLocalStorage from './hooks/useLocalStorage';

function SettingsComponent() {
  // Используем так же, как useState! Значение автоматически сохраняется и читается из localStorage.
  const [theme, setTheme] = useLocalStorage('app-theme', 'light');
  const [username, setUsername] = useLocalStorage('username', '');

  return (
    <div>
      <h1>Настройки</h1>
      <select value={theme} onChange={(e) => setTheme(e.target.value)}>
        <option value="light">Светлая</option>
        <option value="dark">Темная</option>
      </select>

      <input
        type="text"
        placeholder="Введите ваше имя"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
      />
      <p>Привет, {username}! Текущая тема: {theme}.</p>
    </div>
  );
}

Теперь, если пользователь обновит страницу, его настройки темы и имени сохранятся! Хук абсолютно переиспользуем и может работать с любым ключом и значением.

Практическая задача 3:
Создайте хук useSessionStorage для работы с sessionStorage. Добейтесь того, чтобы его API был идентичен useLocalStorage.

Композиция хуков: создание сложной логики из простых частей

Одна из самых мощных особенностей кастомных хуков, их композируемость. Вы можете создавать одни хуки, используя другие. Это как строительство сложного механизма из простых шестеренок.

Представьте, что вы хотите иметь состояние, которое не только сохраняется в localStorage, но и синхронизируется между разными вкладками браузера. Мы можем создать хук useSyncedLocalStorage, используя наш useLocalStorage и нативное событие storage.

jsx
// useSyncedLocalStorage.js
import { useEffect } from 'react';
import useLocalStorage from './useLocalStorage';

function useSyncedLocalStorage(key, initialValue) {
  // Используем наш базовый хук
  const [storedValue, setStoredValue] = useLocalStorage(key, initialValue);

  useEffect(() => {
    // Функция, которая будет вызываться при изменении localStorage из другой вкладки
    const handleStorageChange = (e) => {
      if (e.key === key && e.newValue !== null) {
        // Обновляем состояние, если изменился наш ключ
        setStoredValue(JSON.parse(e.newValue));
      }
    };

    // Подписываемся на событие
    window.addEventListener('storage', handleStorageChange);

    // Отписываемся при размонтировании
    return () => {
      window.removeEventListener('storage', handleStorageChange);
    };
  }, [key, setStoredValue]); // Зависимости: key и setStoredValue

  return [storedValue, setStoredValue];
}

export default useSyncedLocalStorage;

Теперь, если пользователь откроет ваше приложение в двух вкладках и изменит настройки в одной, вторая вкладка автоматически обновится! Мы создали новую, более мощную функциональность, практически не написав нового кода с состоянием и эффектами, а просто скомпоновав уже существующие решения.

Паттерны возвращаемых значений: массив или объект

Вы могли заметить, что в одних хуках я возвращал массив (useToggleuseLocalStorage), а в других объект (useFetch). Это два распространенных паттерна.

  • Возврат массива. Полезен, когда вы хотите, чтобы потребитель хука (компонент) мог сам давать имена возвращаемым значениям. Это имитирует поведение встроенного useState.

    jsx
    const [isOn, toggle] = useToggle(); // Я сам назвал первое значение
    const [theme, setTheme] = useLocalStorage('theme', 'light'); // И здесь тоже
  • Возврат объекта. Удобен, когда хук возвращает много значений и не все из них всегда нужны. Кроме того, порядок значений в объекте не важен.

    jsx
    const { data, loading } = useFetch('/api/posts'); // Мне не нужна error, я ее просто не вытаскиваю
    const { error } = useFetch('/api/users'); // А здесь мне нужна только ошибка

Выбор паттерна зависит от ситуации и вашего вкуса. Для простых хуков с 2-3 значениями часто используют массив. Для более сложных объект.

Советы по созданию и использованию кастомных хуков

  1. Начинайте с дублирования. Не старайтесь создать кастомный хук с самого начала. Сначала напишите логику в компоненте. Если вы видите, что одна и та же логика появляется в двух или более компонентах, вот тогда самое время ее вынести в хук.

  2. Думайте об интерфейсе. Хороший хук имеет простой и понятный интерфейс (что он принимает и что возвращает). Он должен решать одну четкую задачу.

  3. Используйте useCallback и useMemo внутри хуков. Если ваш хук возвращает функции или сложные объекты, оборачивайте их в useCallback и useMemo соответственно, чтобы избежать ненужных ререндеров в компонентах-потребителях.

  4. Не злоупотребляйте. Не нужно выносить в кастомные хуки каждую строчку кода. Если логика используется всего в одном месте, возможно, ей лучше оставаться в компоненте.

Кастомные хуки это мостик между вашим приложением и философией React. Они позволяют вам говорить на языке React, но с вашим собственным акцентом и словарным запасом. Освоив их, вы перестаете быть просто пользователем библиотеки, а становитесь архитектором, способным создавать элегантные, мощные и легко поддерживаемые приложения.

Практическая задача 4 (Финальная):
Создайте хук useForm для управления формами. Он должен:

  • Принимать объект с начальными значениями полей.

  • Возвращать объекты values (текущие значения), errors (ошибки валидации) и функции handleChange (для обновления значения поля) и handleSubmit (для обработки отправки формы).

  • Реализуйте базовую валидацию (например, проверку на пустое поле для email).

Это был восемнадцатый урок из серии «React для начинающих». Если вы хотите освоить React от основ до продвинутых тем, приглашаю вас на полный курс с уроками по React для начинающих. Там вас ждут подробные объяснения, практические проекты и множество задач для закрепления материала. Удачи в изучении!

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий