← Курс/TypeScript с React: типизация хуков#190 из 257+30 XP

TypeScript с React: типизация хуков

useState<T>

TypeScript часто выводит тип из начального значения. Но когда начальное значение null или неоднозначно — явный параметр необходим:

// Тип выводится автоматически:
const [count, setCount] = useState(0)        // number
const [name, setName] = useState('')         // string
const [active, setActive] = useState(false)  // boolean

// Явный тип нужен:
const [user, setUser] = useState<User | null>(null)
const [items, setItems] = useState<string[]>([])
const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle')

useRef<T>

useRef имеет две роли — хранение DOM-ссылки и хранение изменяемого значения:

// DOM-ссылка: передаём null, тип = HTMLInputElement | null
const inputRef = useRef<HTMLInputElement>(null)

// Использование:
useEffect(() => {
  inputRef.current?.focus()  // current может быть null до монтирования
}, [])

// Изменяемое значение (не DOM): передаём начальное значение
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const countRef = useRef(0)  // изменяем без ре-рендера

useCallback типы

// Тип возвращаемой функции выводится автоматически:
const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
  console.log(e.currentTarget.id)
}, [])

// Явный тип если нужно передать в пропс:
const handleChange = useCallback<React.ChangeEventHandler<HTMLInputElement>>(
  (e) => setValue(e.target.value),
  []
)

useMemo с типами

// Тип выводится из возвращаемого значения:
const filtered = useMemo(
  () => items.filter((item) => item.active),
  [items]
)  // тип: Item[]

// Явный параметр если нужно:
const result = useMemo<Record<string, number>>(
  () => computeExpensive(data),
  [data]
)

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

// Проблема: TypeScript выводит тип как (string | boolean)[]
function useToggle(initial: boolean) {
  const [on, setOn] = useState(initial)
  const toggle = useCallback(() => setOn(v => !v), [])
  return [on, toggle]  // ошибка! выведет (boolean | (() => void))[]
}

// Решение 1: as const
function useToggle(initial: boolean) {
  const [on, setOn] = useState(initial)
  const toggle = useCallback(() => setOn(v => !v), [])
  return [on, toggle] as const  // readonly [boolean, () => void]
}

// Решение 2: явный тип возврата
function useToggle(initial: boolean): [boolean, () => void] {
  const [on, setOn] = useState(initial)
  const toggle = useCallback(() => setOn(v => !v), [])
  return [on, toggle]
}

Дженерик-хуки

function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    setLoading(true)
    fetch(url)
      .then(r => r.json() as Promise<T>)
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false))
  }, [url])

  return { data, loading, error }
}

// Использование:
interface User { id: number; name: string }
const { data, loading } = useFetch<User[]>('/api/users')
// data: User[] | null

useReducer с дискриминированными объединениями

type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; message: string }

type Action =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: User[] }
  | { type: 'FETCH_ERROR'; message: string }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'FETCH_START':   return { status: 'loading' }
    case 'FETCH_SUCCESS': return { status: 'success', data: action.payload }
    case 'FETCH_ERROR':   return { status: 'error', message: action.message }
  }
}

Примеры

Реализация кастомных React-хуков в чистом JS: useFetch, useLocalStorage, useDebounce с полной логикой

// Реализуем популярные кастомные хуки без React — только логика.
// В TypeScript каждый хук имеет строгие дженерик-типы.

// --- Упрощённая система хуков ---
const hookState = new Map()
let currentHookKey = null
let hookCursor = 0

function useState(initialValue) {
  const key = currentHookKey
  const idx = hookCursor++
  const stateKey = key + ':' + idx

  if (!hookState.has(stateKey)) {
    hookState.set(stateKey, initialValue)
  }

  const value = hookState.get(stateKey)
  const setter = (newValue) => {
    const next = typeof newValue === 'function' ? newValue(hookState.get(stateKey)) : newValue
    hookState.set(stateKey, next)
  }
  return [value, setter]
}

function runHook(name, hookFn, ...args) {
  currentHookKey = name
  hookCursor = 0
  return hookFn(...args)
}

// --- useLocalStorage<T> ---
// TS: function useLocalStorage<T>(key: string, initialValue: T): [T, (v: T) => void]
function useLocalStorage(key, initialValue) {
  const storage = useLocalStorage._store || (useLocalStorage._store = {})

  const [stored, setStored] = useState(() => {
    try {
      return storage[key] !== undefined ? storage[key] : initialValue
    } catch {
      return initialValue
    }
  })

  const setValue = (value) => {
    const newValue = typeof value === 'function' ? value(stored) : value
    storage[key] = newValue
    setStored(newValue)
  }

  return [stored, setValue]
}

// --- useDebounce<T> ---
// TS: function useDebounce<T>(value: T, delay: number): T
function useDebounce(value, delay) {
  // В реальном React здесь useEffect + setTimeout
  // Симулируем: просто возвращаем значение (в тестах delay=0)
  const [debouncedValue, setDebouncedValue] = useState(value)

  // Сразу обновляем для симуляции
  setDebouncedValue(value)

  return debouncedValue
}

// --- useFetch<T> ---
// TS: function useFetch<T>(url: string): { data: T | null, loading: boolean, error: Error | null }
function useFetch(url) {
  const [state, setState] = useState({
    data: null,
    loading: false,
    error: null
  })

  // Возвращаем функцию fetch для вызова вручную (в реальном хуке — useEffect)
  const fetch = async (mockData) => {
    setState({ data: null, loading: true, error: null })
    try {
      // Симуляция async fetch
      await new Promise(resolve => setTimeout(resolve, 10))
      setState({ data: mockData, loading: false, error: null })
    } catch (err) {
      setState({ data: null, loading: false, error: err })
    }
  }

  return { ...state, fetch }
}

// --- useToggle ---
// TS: function useToggle(initial: boolean): [boolean, () => void]
function useToggle(initial) {
  const [on, setOn] = useState(initial)
  const toggle = () => setOn(v => !v)
  // В TS: return [on, toggle] as const
  return [on, toggle]
}

// --- usePrevious<T> ---
// TS: function usePrevious<T>(value: T): T | undefined
function usePrevious(value) {
  const prev = usePrevious._store || (usePrevious._store = new Map())
  const key = 'prev:' + currentHookKey
  const previous = prev.get(key)
  prev.set(key, value)
  return previous
}

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

console.log('=== useLocalStorage ===')
const [theme, setTheme] = runHook('storage1', useLocalStorage, 'theme', 'light')
console.log('initial theme:', theme)  // light

setTheme('dark')
const [theme2] = runHook('storage1', useLocalStorage, 'theme', 'light')
console.log('after setTheme("dark"):', theme2)  // dark (сохранено в storage)

console.log('\n=== useToggle ===')
const [isOpen, toggle] = runHook('toggle1', useToggle, false)
console.log('initial:', isOpen)  // false
toggle()
const [isOpen2] = runHook('toggle1', useToggle, false)
console.log('after toggle():', hookState.get('toggle1:0'))  // true

console.log('\n=== usePrevious ===')
const prev1 = runHook('prev1', usePrevious, 'first')
console.log('prev (first call):', prev1)  // undefined

const prev2 = runHook('prev1', usePrevious, 'second')
console.log('prev (second call):', prev2)  // first

const prev3 = runHook('prev1', usePrevious, 'third')
console.log('prev (third call):', prev3)  // second

console.log('\n=== useFetch ===')
const { data, loading, error, fetch } = runHook('fetch1', useFetch, '/api/users')
console.log('initial state: data=' + data + ', loading=' + loading)

// Async fetch
fetch([{ id: 1, name: 'Алексей' }, { id: 2, name: 'Мария' }]).then(() => {
  const state = hookState.get('fetch1:0')
  console.log('after fetch: loading=' + state.loading)
  console.log('data:', state.data)
})