Средний 14 мин чтения

TypeScript с React

Типизация React-компонентов — FC, Props, useState, useRef, useEffect, useContext, handle-функции и work with external libraries.

typescriptreactFCpropsuseStateuseRefhooks

React и TypeScript

React хорошо типизируется через @types/react. Все хуки имеют встроенные типы, а компоненты описываются через generic-типы.

Типизация компонентов

Function Components (FC)

interface GreetingProps {
  name: string
  age?: number
}

function Greeting({ name, age }: GreetingProps) {
  return (
    <div>
      <h1>Привет, {name}!</h1>
      {age && <p>Возраст: {age}</p>}
    </div>
  )
}

Тип React.FC (или React.FunctionComponent) добавляет неявный возврат ReactElement | null и поддержку children:

const Greeting: React.FC<GreetingProps> = ({ name, age }) => {
  return <h1>Привет, {name}!</h1>
}

Многие команды предпочитают обычные функции — они гибче и не добавляют лишний children.

children

interface LayoutProps {
  children: React.ReactNode
  title: string
}

function Layout({ children, title }: LayoutProps) {
  return (
    <div>
      <h1>{title}</h1>
      <main>{children}</main>
    </div>
  )
}

Типы для children:

ТипКогда использовать
React.ReactNodeЛюбой контент (строка, число, JSX, null)
React.ReactElementТолько JSX-элемент
stringТолько текст
JSX.ElementКонкретный JSX

Пропсы с функциями-callback

interface ButtonProps {
  label: string
  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
  variant?: 'primary' | 'secondary' | 'danger'
  disabled?: boolean
}

function Button({ label, onClick, variant = 'primary', disabled }: ButtonProps) {
  return (
    <button onClick={onClick} disabled={disabled} className={`btn btn-${variant}`}>
      {label}
    </button>
  )
}

Пропсы с render-функциями

interface ListProps<T> {
  items: T[]
  renderItem: (item: T, index: number) => React.ReactNode
  keyExtractor: (item: T) => string | number
}

function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  )
}

useState

TypeScript выводит тип из начального значения:

const [count, setCount] = useState(0) // number
const [name, setName] = useState('') // string
const [active, setActive] = useState(false) // boolean

Для сложных типов или null:

interface User {
  id: number
  name: string
  email: string
}

const [user, setUser] = useState<User | null>(null)
const [items, setItems] = useState<User[]>([])

Union-типы:

type Status = 'idle' | 'loading' | 'success' | 'error'

const [status, setStatus] = useState<Status>('idle')

Функциональное обновление:

const [count, setCount] = useState(0)

setCount(prev => prev + 1) // TypeScript знает, что prev: number

useRef

Ссылка на DOM-элемент

const inputRef = useRef<HTMLInputElement>(null)

useEffect(() => {
  inputRef.current?.focus()
}, [])

return <input ref={inputRef} />

Ссылка на произвольное значение

const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)

useEffect(() => {
  timerRef.current = setTimeout(() => {
    console.log('Таймер')
  }, 1000)

  return () => {
    if (timerRef.current) clearTimeout(timerRef.current)
  }
}, [])

Ссылка на компонент

const modalRef = useRef<React.ComponentRef<typeof Modal>>(null)
// или
const modalRef = useRef<HTMLDialogElement>(null)

useEffect

useEffect не требует типизации — он всегда возвращает void. Но функции внутри могут потребовать типов:

useEffect(() => {
  const controller = new AbortController()

  async function load() {
    const res = await fetch('/api/users', { signal: controller.signal })
    const data: User[] = await res.json()
    setUsers(data)
  }

  load()

  return () => controller.abort()
}, [])

useContext

interface ThemeContextType {
  theme: 'light' | 'dark'
  toggle: () => void
}

const ThemeContext = createContext<ThemeContextType | null>(null)

function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error('useTheme должен использоваться внутри ThemeProvider')
  }
  return context
}

function ThemedButton() {
  const { theme, toggle } = useTheme()
  return <button onClick={toggle}>Тема: {theme}</button>
}

Паттерн с null и проверкой лучше, чем createContext({}) — TypeScript заставит обработать случай, когда контекст не предоставлен.

useReducer

interface State {
  count: number
  step: number
}

type Action =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'setStep'; payload: number }
  | { type: 'reset' }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + state.step }
    case 'decrement':
      return { ...state, count: state.count - state.step }
    case 'setStep':
      return { ...state, step: action.payload }
    case 'reset':
      return { count: 0, step: 1 }
  }
}

const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 })

dispatch({ type: 'increment' })
dispatch({ type: 'setStep', payload: 5 })

Discriminated union в Action гарантирует, что payload доступен только для setStep.

Обработка событий

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

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    console.log(value)
  }

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

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') {
      e.preventDefault()
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={value} onChange={handleChange} onKeyDown={handleKeyDown} />
      <button type="submit">Отправить</button>
    </form>
  )
}

Шпаргалка по типам событий:

СобытиеТип
ClickReact.MouseEvent<T>
Change (input)React.ChangeEvent<T>
SubmitReact.FormEvent<T>
KeyDown/UpReact.KeyboardEvent<T>
Focus/BlurReact.FocusEvent<T>
DragReact.DragEvent<T>
ScrollReact.UIEvent<T>

forwardRef и generic-компоненты

interface InputProps {
  label: string
  error?: string
}

const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error }, ref) => {
    return (
      <div>
        <label>{label}</label>
        <input ref={ref} />
        {error && <span className="error">{error}</span>}
      </div>
    )
  }
)

Generic-компонент с forwardRef:

interface ListProps<T> {
  items: T[]
  renderItem: (item: T) => React.ReactNode
}

const List = forwardRef<HTMLUListElement, ListProps<any>>(
  ({ items, renderItem }, ref) => {
    return (
      <ul ref={ref}>
        {items.map((item, i) => (
          <li key={i}>{renderItem(item)}</li>
        ))}
      </ul>
    )
  }
) as <T>(
  props: ListProps<T> & { ref?: React.Ref<HTMLUListElement> }
) => React.ReactElement

Практический пример — типизированный Form

interface FormValues {
  email: string
  password: string
  remember: boolean
}

function LoginForm() {
  const [values, setValues] = useState<FormValues>({
    email: '',
    password: '',
    remember: false,
  })

  const [errors, setErrors] = useState<Partial<Record<keyof FormValues, string>>>({})

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()

    const newErrors: typeof errors = {}
    if (!values.email) newErrors.email = 'Введите email'
    if (!values.password) newErrors.password = 'Введите пароль'

    if (Object.keys(newErrors).length) {
      setErrors(newErrors)
      return
    }

    console.log('Отправка:', values)
  }

  const updateField = <K extends keyof FormValues>(key: K, value: FormValues[K]) => {
    setValues(prev => ({ ...prev, [key]: value }))
    setErrors(prev => ({ ...prev, [key]: undefined }))
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={values.email}
        onChange={e => updateField('email', e.target.value)}
        placeholder="Email"
      />
      {errors.email && <span>{errors.email}</span>}

      <input
        type="password"
        value={values.password}
        onChange={e => updateField('password', e.target.value)}
        placeholder="Пароль"
      />
      {errors.password && <span>{errors.password}</span>}

      <label>
        <input
          type="checkbox"
          checked={values.remember}
          onChange={e => updateField('remember', e.target.checked)}
        />
        Запомнить
      </label>

      <button type="submit">Войти</button>
    </form>
  )
}

Итог

React отлично типизируется. Компоненты — через обычные функции с типизированными props, хуки — через generic-параметры. useState<T> и useRef<T> для сложных случаев, useContext с null-паттерном для обязательной проверки, discriminated unions для useReducer. Типы событий (React.MouseEvent, React.ChangeEvent) обеспечивают типобезопасность обработчиков.