Фото Tatiana Rodriguez

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

Трудно представить код современного React-приложения без таких функций как useState, useEffect, useRef и так далее.

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

Создание кастом-хуков это прекрасная возможность выделить часто переиспользуемый код в отдельные сущности.

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

Ниже мы рассмотрим примеры некоторых из них.  

1. useToggle

Приходилось ли вам когда-нибудь создавать useState, который содержал в себе только два значения true и false и назывался как-то вроде isActive, isChecked или isOpen?

Если ответ да - то вы определенно попали по адресу! Первый хук, который мы рассмотрим, инкапсулирует в себе эту логику, возвращая значение и методы для изменения его состояния.

import { useCallback, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'

export function useToggle(
  defaultValue?: boolean,
): [boolean, () => void, Dispatch<SetStateAction<boolean>>] {
  const [value, setValue] = useState(!!defaultValue)

  const toggle = useCallback(() => {
    setValue((x) => !x)
  }, [])

  return [value, toggle, setValue]
}

Его можно легко расширить функциями, которые будут явно устанавливать значение состояния в true или false.

Рассмотрим пример использования:

export function Component() {
  const [value, toggle, setValue] = useToggle()

  return (
    <>
      <button onClick={toggle}>toggle</button>
      <button onClick={() => setValue(false)}>hide</button>

      {value && <div>Hello!</div>}
    </>
  )
}

2. useHover

Случались ли у вас такое, что css :hover по каким-либо причинам использовать было невозможно и ничего не оставалось, кроме как сымитировать это поведение с помощью mouseEnter и mouseLeave?

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

import { useRef, useState, useEffect } from 'react'
import type { RefObject } from 'react'

export function useHover<T extends HTMLElement = HTMLElement>(): [
  RefObject<T>,
  boolean,
] {
  const ref = useRef<T>(null)
  const [isHovered, setIsHovered] = useState(false)

  useEffect(() => {
    const element = ref.current
    if (!element) return

    const handleMouseEnter = () => setIsHovered(true)
    const handleMouseLeave = () => setIsHovered(false)

    element.addEventListener('mouseenter', handleMouseEnter)
    element.addEventListener('mouseleave', handleMouseLeave)

    return () => {
      element.removeEventListener('mouseenter', handleMouseEnter)
      element.removeEventListener('mouseleave', handleMouseLeave)
    }
  }, [])

  return [ref, isHovered]
}

Использование этого хука несколько нестандартное, давайте рассмотрим на примере:

export function Component() {
  const [hoverRef, isHovered] = useHover<HTMLDivElement>()

  return (
    <div
      ref={hoverRef}
      style={{ backgroundColor: isHovered ? 'lightblue' : 'lightgray' }}
    >
      {isHovered ? 'hovered' : 'not hovered'}
    </div>
  )
}

3. useDerivedState

Порой, в компоненте мы создаем useState, начальным значением которого является какое-либо значение из пропсов.

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

Чтобы этого избежать мы можем воспользоваться следующим хуком:

export function useDerivedState<T>(
  propValue: T,
): [T, Dispatch<SetStateAction<T>>] {
  const [state, setState] = useState(propValue)

  useEffect(() => {
    setState(propValue)
  }, [propValue])

  return [state, setState]
}

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

export function Component({ initialName }: { initialName: string }) {
  const [name, setName] = useDerivedState(initialName)

  return (
    <>
      <input value={name} onChange={(e) => setName(e.target.value)} />

      <div>Current name: {name}</div>
    </>
  )
}

4. useEventCallback

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

Однако, если в массиве зависимостей этой функции будут значения, которые изменились - функция будет создана заново.

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

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

export function useEventCallback<I extends unknown[], O>(
  fn: (...args: I) => O,
): (...args: I) => O {
  const ref = useRef<(...args: I) => O>()

  useLayoutEffect(() => {
    ref.current = fn
  }, [fn])

  return useCallback((...args) => {
    const { current } = ref

    if (current == null) {
      throw new Error(
        'callback created in useEventCallback can only be called from event handlers',
      )
    }

    return current(...args)
  }, [])
}

Чаще всего этот хук используется для коллбэков, вызов которых отложен во времени и инициируется пользователем. Удачным примером будет замена им обычных коллбэков для передачи в onClick:

export function Component() {
  const [count, setCount] = useState(0)

  const increment = useEventCallback(() => {
    setCount((prev) => prev + 1)
  })

  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>Add</button>
    </div>
  )
}

5. useDebouncedCallback

При взаимодействии пользователя с интерйфесом через такие события как: ввод текста, изменение ширины окна браузера, скролл - может возникать чрезмерно большое количество вызовов функций-коллбэков.

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

import { useEffect, useMemo, useRef } from 'react'
import debounce from 'lodash.debounce'

export function useDebouncedCallback<T extends (...args: any[]) => any>(
  func: T,
  delay = 500,
) {
  const funcRef = useRef(func)

  useEffect(() => {
    funcRef.current = func
  }, [func])

  const debounced = useMemo(() => {
    const debouncedFn = debounce(
      (...args: Parameters<T>) => funcRef.current(...args),
      delay,
    )
    return debouncedFn
  }, [delay])

  useEffect(() => {
    return () => {
      debounced.cancel()
    }
  }, [debounced])

  return debounced
}

Этот хук можно расширить такими вспомогательными функциями как cancel, isPending и flush.

Рассмотрим пример использования:

export function Component() {
  const [value, setValue] = useState('')

  const debouncedSearch = useDebouncedCallback((query: string) => {
    console.log('Search by:', query)
  }, 500)

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = e.target.value
    setValue(newValue)
    debouncedSearch(newValue)
  }

  return (
    <input
      type="text"
      placeholder="Search..."
      value={value}
      onChange={handleChange}
    />
  )
}

Вот и все! Количество и функционал кастом-хуков может быть самым разнообразным, все ограничено лишь вашей фантазией и потребностями.

За большим количеством примеров вы можете обратиться в такие библиотеки как react-use или usehooks-ts, а также многие другие.

Комментарии (17)


  1. adminNiochen
    07.02.2025 22:58

    В useEventCallback дичь какая-то, зачем там layouteffect? Если хочешь юзколбек которому не нужен массив зависимостей и ссылка всегда стабильная, можно через рефы сделать


    1. Finesse
      07.02.2025 22:58

      Да, вместо использования useLayoutEffect можно записывать новую функцию в ref прям во время вызова хука. Но это сомнительный паттерн использования Реакта.

      Вообще, на месте создателей Реакта, я бы сделал так, чтобы useCallback всегда возвращал одну и ту же ссылку на функцию, потому что случаи, когда нужно менять ссылку, намного более редкие.


      1. adminNiochen
        07.02.2025 22:58

        Это нормальный паттерн использования реакт, когда реф не используется для дом элемента (если у тебя в return компонента какому-то диву реф присваивается, этот код выполнится тупо позже чем любые синхронные действия с рефом до return и всё перетрёт)


        1. Finesse
          07.02.2025 22:58

          В большинстве случаев ок. Но не во всех:

          But for a long time the React team has been talking about a "Concurrent Mode" where an update might start (render gets called), but then get interrupted by a higher priority update.

          In this sort of situation it's possible for the ref to end up out of sync with the actual state of the rendered component, if it's updated in a render that gets cancelled.


  1. Zukomux
    07.02.2025 22:58

    Если useDerivedState нужен для первоначального состояния, то может использовать нормальные инструменты форм? Например Final Form, потому как в вашем примере работа идёт именно с полями ввода данных


    1. AndreyVolfman Автор
      07.02.2025 22:58

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


  1. Gary_Ihar
    07.02.2025 22:58

    Что будет с useHover, если элемент который к нему подключен рендерится по условию и вдруг исчез, а потом появился?

    В 4 хуке вы упомянули про производительность, но в хук воткнули useLayoutEffect. Зачем он там? То же самое и в 5


  1. BerkutEagle
    07.02.2025 22:58

    useDerivedState в такой реализации может добавить головной боли. Сначала компонент перерисуется с обновлёнными пропсами, но старым стейтом. Затем, после setState внутри useEffect, перерисуется с новыми пропсами и новым стейтом.


    1. Alexandroppolus
      07.02.2025 22:58

      согласен, лучше сделать как-то так:

      export function useDerivedState<T>(
        propValue: T,
      ): [T, Dispatch<SetStateAction<T>>] {
        const [state, setState] = useState(propValue);
        const ref = useRef(propValue);
      
        useEffect(() => {
          ref.currrent = propValue;
          setState(propValue);
        }, [propValue]);
      
        return [propValue === ref.current ? state : propValue, setState];
      }

      Правда, от лишнего рендера тут не уйти, но хотя бы с актуальным состоянием.


      1. BerkutEagle
        07.02.2025 22:58

        Можно заменить useEffect на useLayoutEffect и мутировать стейт там. Минус производительность (одно дополнительное вычисление VDOM из-за мутации стейта), но зато попадаем в одну отрисовку DOM'а с актуальным состоянием


      1. BerkutEagle
        07.02.2025 22:58

        На рефах у меня получилось что-то такое

        export function useDerivedState(propValue) {
          const [state, setState] = useState(propValue);
          const stateChanged = useRef(false);
          const lastValue = useRef(propValue);
        
          if (lastValue.current !== propValue) {
            stateChanged.current = false;
            lastValue.current = propValue;
          }
        
          return [
            stateChanged.current ? state : propValue,
            useCallback((stateValue) => {
              if (typeof stateValue === "function") {
                setState((prevValue) => {
                  if (stateChanged.current) {
                    return stateValue(prevValue);
                  } else {
                    stateChanged.current = true;
                    return stateValue(lastValue.current);
                  }
                });
              } else {
                stateChanged.current = true;
                setState(stateValue);
              }
            }, []),
          ];
        }


      1. BerkutEagle
        07.02.2025 22:58

        Вариант на useSyncExternalStore

        function getStore(propValue) {
          let listener = null;
          let val = propValue;
          let propVal = propValue;
          let stateVal = propValue;
        
          return {
            clean() {
              listener = null;
            },
            subscribe(l) {
              listener = l;
            },
            getValue() {
              return val;
            },
            setPropValue(nextVal) {
              if (propVal !== nextVal) {
                val = propVal = nextVal;
                if (listener) listener();
              }
            },
            setStateValue(nextVal) {
              const nextStateValue =
                typeof nextVal === "function" ? nextVal(val) : nextVal;
              if (nextStateValue !== stateVal) {
                val = stateVal = nextStateValue;
                if (listener) listener();
              }
            },
          };
        }
        
        export function useDerivedState(propValue) {
          const store = useMemo(() => getStore(propValue), []);
        
          store.setPropValue(propValue);
        
          return [
            useSyncExternalStore(store.subscribe, store.getValue),
            store.setStateValue,
          ];
        }


  1. Alexandroppolus
    07.02.2025 22:58

    В реализации useHover есть довольно типичная ошибка (упомянутая в комментарии выше), о ней писал здесь. Альтернативный вариант - использовать функциональный реф вместо useRef.

    Несколько мелких поинтов о useEventCallback:

    1) Если вызвать созданную им функцию внутри layoutEffect дочернего компонента, то реф ещё не будет обновлен, потому что сначала вызываются эффекты в чилдах. Для обновления рефа больше подходит useInsertionEffect.
    2) throw там может вызваться только на самом первом рендере, а на последующих в рефе будет сидеть устаревшая функция и проверка не сработает. По нормальному это не проверить, потому предлагаю просто не заморачиваться.
    3) Типизация не поддерживает перегрузки функций
    4) Функция, передаваемая в хук, может быть необязательной, например это опциональный проп. Это хорошо бы поддержать, чтобы потом не городить костыли снаружи.

    Итого, со всеми поправками
    function useEventCallback<T extends undefined | null | ((...args: never[]) => unknown)>(
        func: T,
    ): T {
        const refCallee = useRef(func);
    
        useInsertionEffect(() => {
            refCallee.current = func;
        });
    
        const callback = useCallback((...args: never[]) => refCallee.current?.(...args), []) as T;
    
        return func && callback;
    }


    1. AndreyVolfman Автор
      07.02.2025 22:58

      Хорошие комментарии, учту!


  1. shsv382
    07.02.2025 22:58

    Ну кстати прикольная подборка, еще useFetch добавить, и можно в библиотеку компоновать! Спасибо!


    1. AndreyVolfman Автор
      07.02.2025 22:58

      Благодарю


  1. DDeenis
    07.02.2025 22:58

    В приведенном коде useDerivedState использование useEffect нежелательно, так как может привести к лишним перерисовкам и другим неприятным багам. Заменить useEffect можно так:

    function useDerivedState<T>(propValue: T) {
      const [state, setState] = useState(propValue);
      const [prevState, setPrevState] = useState(propValue);
    
      if (prevState !== propValue) {
        setState(propValue);
        setPrevState(propValue);
      }
    
      return [state, setState] as const;
    }