← React/Кастомные хуки#268 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksТема: React: хуки и экосистема

Кастомные хуки

Что такое кастомный хук

Кастомный хук — это обычная JavaScript-функция, имя которой начинается с use, внутри которой можно вызывать другие хуки. Это главный механизм переиспользования логики в React.

До хуков для переиспользования логики использовались HOC (Higher-Order Components) и render props — громоздкие паттерны. Кастомные хуки заменяют оба подхода элегантно и без лишней обёртки.

// Это кастомный хук — просто функция с use-префиксом
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  })

  useEffect(() => {
    const handler = () => setSize({
      width: window.innerWidth,
      height: window.innerHeight
    })
    window.addEventListener('resize', handler)
    return () => window.removeEventListener('resize', handler)
  }, [])

  return size
}

// Используем в любом компоненте
function App() {
  const { width, height } = useWindowSize()
  return <p>Размер: {width} x {height}</p>
}

Каждый вызов хука имеет изолированное состояние — два компонента, использующие useWindowSize, не делят состояние между собой.

useFetch: загрузка данных

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

function useFetch(url) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    let cancelled = false  // защита от race condition

    setLoading(true)
    fetch(url)
      .then(r => r.json())
      .then(data => {
        if (!cancelled) {
          setData(data)
          setLoading(false)
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err.message)
          setLoading(false)
        }
      })

    return () => { cancelled = true }  // cleanup при смене url
  }, [url])

  return { data, loading, error }
}

// Использование — чисто и просто
function UserProfile({ id }) {
  const { data, loading, error } = useFetch(`/api/users/${id}`)

  if (loading) return <Spinner />
  if (error) return <Error message={error} />
  return <Profile user={data} />
}

useLocalStorage: синхронизация с localStorage

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const stored = localStorage.getItem(key)
      return stored ? JSON.parse(stored) : initialValue
    } catch {
      return initialValue
    }
  })

  const setStoredValue = (newValue) => {
    try {
      const valueToStore = typeof newValue === 'function'
        ? newValue(value)
        : newValue
      setValue(valueToStore)
      localStorage.setItem(key, JSON.stringify(valueToStore))
    } catch (err) {
      console.error('localStorage error:', err)
    }
  }

  return [value, setStoredValue]
}

// Использование как обычный useState, но с персистентностью
const [theme, setTheme] = useLocalStorage('theme', 'light')

useDebounce: задержка обновления

Полезен для поиска — чтобы не делать запрос при каждом нажатии клавиши:

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => clearTimeout(timer)  // сбрасываем таймер при каждом изменении
  }, [value, delay])

  return debouncedValue
}

function SearchInput() {
  const [query, setQuery] = useState('')
  const debouncedQuery = useDebounce(query, 500)

  // Этот эффект запустится только через 500мс после остановки ввода
  useEffect(() => {
    if (debouncedQuery) fetchResults(debouncedQuery)
  }, [debouncedQuery])

  return <input value={query} onChange={e => setQuery(e.target.value)} />
}

Правила хуков

Кастомные хуки подчиняются тем же правилам, что и встроенные:

1. Вызывайте хуки только на верхнем уровне — не внутри условий, циклов, вложенных функций

2. Вызывайте хуки только в React-компонентах или других кастомных хуках

3. Имя должно начинаться с use

useOnClickOutside: клик вне элемента

function useOnClickOutside(ref, handler) {
  useEffect(() => {
    const listener = (event) => {
      if (!ref.current || ref.current.contains(event.target)) return
      handler(event)
    }
    document.addEventListener('mousedown', listener)
    return () => document.removeEventListener('mousedown', listener)
  }, [ref, handler])
}

// Использование для закрытия дропдауна
function Dropdown() {
  const [open, setOpen] = useState(false)
  const ref = useRef(null)

  useOnClickOutside(ref, () => setOpen(false))

  return <div ref={ref}>{open && <Menu />}</div>
}

Примеры

Реализация кастомных хуков в виде функций с замыканием: useFetch, useDebounce, useLocalStorage без React

// Кастомные хуки — это просто функции, инкапсулирующие логику.
// Реализуем их на чистом JS с тем же интерфейсом, что в React.

// --- useFetch: загрузка данных ---

function useFetch(url) {
  // Внутреннее состояние хука
  const state = {
    data: null,
    loading: true,
    error: null,
    subscribers: []
  }

  const notify = () => state.subscribers.forEach(fn => fn({ ...state }))

  // Логика загрузки
  let cancelled = false

  fetch(url)
    .then(r => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`)
      return r.json()
    })
    .then(data => {
      if (cancelled) return
      state.data = data
      state.loading = false
      notify()
    })
    .catch(err => {
      if (cancelled) return
      state.error = err.message
      state.loading = false
      notify()
    })

  return {
    getState: () => ({ ...state }),
    subscribe: (fn) => { state.subscribers.push(fn) },
    cancel: () => { cancelled = true }
  }
}

// --- useDebounce: задержка значения ---

function useDebounce(initialValue, delay) {
  let currentValue = initialValue
  let debouncedValue = initialValue
  let timerId = null
  const subscribers = []

  const notify = () => subscribers.forEach(fn => fn(debouncedValue))

  return {
    setValue(newValue) {
      currentValue = newValue
      // Сбрасываем предыдущий таймер — как useEffect cleanup
      clearTimeout(timerId)
      timerId = setTimeout(() => {
        debouncedValue = currentValue
        console.log(`  [useDebounce] обновлено через ${delay}мс: "${debouncedValue}"`)
        notify()
      }, delay)
    },
    getValue: () => debouncedValue,
    subscribe: (fn) => subscribers.push(fn),
    destroy: () => clearTimeout(timerId)
  }
}

// --- useLocalStorage: синхронизация с хранилищем ---

function useLocalStorage(key, initialValue) {
  // Имитируем localStorage (в реальности используем window.localStorage)
  const storage = new Map()

  const read = () => {
    try {
      const item = storage.get(key)
      return item !== undefined ? JSON.parse(item) : initialValue
    } catch {
      return initialValue
    }
  }

  let value = read()

  return {
    getValue: () => value,
    setValue(newValue) {
      value = typeof newValue === 'function' ? newValue(value) : newValue
      storage.set(key, JSON.stringify(value))
      console.log('  [useLocalStorage] "' + key + '" сохранено:', value)
    },
    // Симуляция чтения при "монтировании" нового компонента
    init: () => { value = read(); return value }
  }
}

// --- Демонстрация ---

console.log('=== useDebounce ===')
const search = useDebounce('', 300)

search.subscribe((val) => console.log('  [поиск] запрос к API: "' + val + '"'))

// Быстрый ввод — только последнее значение дойдёт до API
console.log('Пользователь быстро набирает: r -> re -> rea -> реакт')
search.setValue('r')
search.setValue('re')
search.setValue('rea')
search.setValue('реакт')
// Только 'реакт' попадёт в API через 300мс

setTimeout(() => {
  console.log('Дебаунсированное значение:', search.getValue())
  search.destroy()

  console.log('
=== useLocalStorage ===')
  const themeStorage = useLocalStorage('theme', 'light')
  console.log('Начальное значение:', themeStorage.getValue()) // 'light'

  themeStorage.setValue('dark')
  console.log('После смены:', themeStorage.getValue()) // 'dark'

  themeStorage.setValue(prev => prev === 'dark' ? 'light' : 'dark')
  console.log('После toggle:', themeStorage.getValue()) // 'light'

  console.log('
=== useFetch (симуляция) ===')
  // В реальном коде: const { data, loading, error } = useFetch('/api/users')
  console.log('В React: const { data, loading, error } = useFetch(url)')
  console.log('Хук инкапсулирует: fetch, useState, useEffect, отмену запроса')
}, 500)

Кастомные хуки

Что такое кастомный хук

Кастомный хук — это обычная JavaScript-функция, имя которой начинается с use, внутри которой можно вызывать другие хуки. Это главный механизм переиспользования логики в React.

До хуков для переиспользования логики использовались HOC (Higher-Order Components) и render props — громоздкие паттерны. Кастомные хуки заменяют оба подхода элегантно и без лишней обёртки.

// Это кастомный хук — просто функция с use-префиксом
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  })

  useEffect(() => {
    const handler = () => setSize({
      width: window.innerWidth,
      height: window.innerHeight
    })
    window.addEventListener('resize', handler)
    return () => window.removeEventListener('resize', handler)
  }, [])

  return size
}

// Используем в любом компоненте
function App() {
  const { width, height } = useWindowSize()
  return <p>Размер: {width} x {height}</p>
}

Каждый вызов хука имеет изолированное состояние — два компонента, использующие useWindowSize, не делят состояние между собой.

useFetch: загрузка данных

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

function useFetch(url) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    let cancelled = false  // защита от race condition

    setLoading(true)
    fetch(url)
      .then(r => r.json())
      .then(data => {
        if (!cancelled) {
          setData(data)
          setLoading(false)
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err.message)
          setLoading(false)
        }
      })

    return () => { cancelled = true }  // cleanup при смене url
  }, [url])

  return { data, loading, error }
}

// Использование — чисто и просто
function UserProfile({ id }) {
  const { data, loading, error } = useFetch(`/api/users/${id}`)

  if (loading) return <Spinner />
  if (error) return <Error message={error} />
  return <Profile user={data} />
}

useLocalStorage: синхронизация с localStorage

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const stored = localStorage.getItem(key)
      return stored ? JSON.parse(stored) : initialValue
    } catch {
      return initialValue
    }
  })

  const setStoredValue = (newValue) => {
    try {
      const valueToStore = typeof newValue === 'function'
        ? newValue(value)
        : newValue
      setValue(valueToStore)
      localStorage.setItem(key, JSON.stringify(valueToStore))
    } catch (err) {
      console.error('localStorage error:', err)
    }
  }

  return [value, setStoredValue]
}

// Использование как обычный useState, но с персистентностью
const [theme, setTheme] = useLocalStorage('theme', 'light')

useDebounce: задержка обновления

Полезен для поиска — чтобы не делать запрос при каждом нажатии клавиши:

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => clearTimeout(timer)  // сбрасываем таймер при каждом изменении
  }, [value, delay])

  return debouncedValue
}

function SearchInput() {
  const [query, setQuery] = useState('')
  const debouncedQuery = useDebounce(query, 500)

  // Этот эффект запустится только через 500мс после остановки ввода
  useEffect(() => {
    if (debouncedQuery) fetchResults(debouncedQuery)
  }, [debouncedQuery])

  return <input value={query} onChange={e => setQuery(e.target.value)} />
}

Правила хуков

Кастомные хуки подчиняются тем же правилам, что и встроенные:

1. Вызывайте хуки только на верхнем уровне — не внутри условий, циклов, вложенных функций

2. Вызывайте хуки только в React-компонентах или других кастомных хуках

3. Имя должно начинаться с use

useOnClickOutside: клик вне элемента

function useOnClickOutside(ref, handler) {
  useEffect(() => {
    const listener = (event) => {
      if (!ref.current || ref.current.contains(event.target)) return
      handler(event)
    }
    document.addEventListener('mousedown', listener)
    return () => document.removeEventListener('mousedown', listener)
  }, [ref, handler])
}

// Использование для закрытия дропдауна
function Dropdown() {
  const [open, setOpen] = useState(false)
  const ref = useRef(null)

  useOnClickOutside(ref, () => setOpen(false))

  return <div ref={ref}>{open && <Menu />}</div>
}

Примеры

Реализация кастомных хуков в виде функций с замыканием: useFetch, useDebounce, useLocalStorage без React

// Кастомные хуки — это просто функции, инкапсулирующие логику.
// Реализуем их на чистом JS с тем же интерфейсом, что в React.

// --- useFetch: загрузка данных ---

function useFetch(url) {
  // Внутреннее состояние хука
  const state = {
    data: null,
    loading: true,
    error: null,
    subscribers: []
  }

  const notify = () => state.subscribers.forEach(fn => fn({ ...state }))

  // Логика загрузки
  let cancelled = false

  fetch(url)
    .then(r => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`)
      return r.json()
    })
    .then(data => {
      if (cancelled) return
      state.data = data
      state.loading = false
      notify()
    })
    .catch(err => {
      if (cancelled) return
      state.error = err.message
      state.loading = false
      notify()
    })

  return {
    getState: () => ({ ...state }),
    subscribe: (fn) => { state.subscribers.push(fn) },
    cancel: () => { cancelled = true }
  }
}

// --- useDebounce: задержка значения ---

function useDebounce(initialValue, delay) {
  let currentValue = initialValue
  let debouncedValue = initialValue
  let timerId = null
  const subscribers = []

  const notify = () => subscribers.forEach(fn => fn(debouncedValue))

  return {
    setValue(newValue) {
      currentValue = newValue
      // Сбрасываем предыдущий таймер — как useEffect cleanup
      clearTimeout(timerId)
      timerId = setTimeout(() => {
        debouncedValue = currentValue
        console.log(`  [useDebounce] обновлено через ${delay}мс: "${debouncedValue}"`)
        notify()
      }, delay)
    },
    getValue: () => debouncedValue,
    subscribe: (fn) => subscribers.push(fn),
    destroy: () => clearTimeout(timerId)
  }
}

// --- useLocalStorage: синхронизация с хранилищем ---

function useLocalStorage(key, initialValue) {
  // Имитируем localStorage (в реальности используем window.localStorage)
  const storage = new Map()

  const read = () => {
    try {
      const item = storage.get(key)
      return item !== undefined ? JSON.parse(item) : initialValue
    } catch {
      return initialValue
    }
  }

  let value = read()

  return {
    getValue: () => value,
    setValue(newValue) {
      value = typeof newValue === 'function' ? newValue(value) : newValue
      storage.set(key, JSON.stringify(value))
      console.log('  [useLocalStorage] "' + key + '" сохранено:', value)
    },
    // Симуляция чтения при "монтировании" нового компонента
    init: () => { value = read(); return value }
  }
}

// --- Демонстрация ---

console.log('=== useDebounce ===')
const search = useDebounce('', 300)

search.subscribe((val) => console.log('  [поиск] запрос к API: "' + val + '"'))

// Быстрый ввод — только последнее значение дойдёт до API
console.log('Пользователь быстро набирает: r -> re -> rea -> реакт')
search.setValue('r')
search.setValue('re')
search.setValue('rea')
search.setValue('реакт')
// Только 'реакт' попадёт в API через 300мс

setTimeout(() => {
  console.log('Дебаунсированное значение:', search.getValue())
  search.destroy()

  console.log('
=== useLocalStorage ===')
  const themeStorage = useLocalStorage('theme', 'light')
  console.log('Начальное значение:', themeStorage.getValue()) // 'light'

  themeStorage.setValue('dark')
  console.log('После смены:', themeStorage.getValue()) // 'dark'

  themeStorage.setValue(prev => prev === 'dark' ? 'light' : 'dark')
  console.log('После toggle:', themeStorage.getValue()) // 'light'

  console.log('
=== useFetch (симуляция) ===')
  // В реальном коде: const { data, loading, error } = useFetch('/api/users')
  console.log('В React: const { data, loading, error } = useFetch(url)')
  console.log('Хук инкапсулирует: fetch, useState, useEffect, отмену запроса')
}, 500)

Задание

Напиши кастомный хук useCounter(initial) который возвращает { count, increment, decrement, reset }. Хук использует useState внутри. Затем напиши кастомный хук useToggle(initial) возвращающий [value, toggle]. Используй оба хука в компоненте App.

Подсказка

useCounter использует useState(initial). increment: c => c + 1. reset: () => setCount(initial). useToggle: setValue(v => !v). В App вызывай хуки как обычные функции: useCounter(0), useToggle(true).

Загружаем среду выполнения...
Загружаем AI-помощника...