javascript

Пять нужных кастом-хуков для React

  • воскресенье, 9 февраля 2025 г. в 00:00:05
https://habr.com/ru/articles/880570/
Фото 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, а также многие другие.