← TypeScript/TypeScript в React#242 из 383← ПредыдущийСледующий →+35 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: TypeScript setТермин: TypeScriptМаршрут: старт с нуля
← НазадДалее →

TypeScript в React

Зачем TypeScript в React-проектах

В маленьком демо React без типов может казаться быстрее, но в реальном проекте без TypeScript начинают накапливаться «тихие» баги: компонент получает не те props, API вернуло неожиданный формат, событие обработано не тем типом элемента. TypeScript решает это на этапе разработки.

Основная идея: типизировать границы системы. В React это прежде всего props компонентов, результаты API, состояние и обработчики событий. Когда эти точки покрыты типами, большая часть ошибок ловится до запуска приложения.

Что важно освоить в первую очередь

Не нужно сразу пытаться типизировать всё на 100%. Начни с базового минимума:

  • интерфейсы props для компонентов;
  • типы для useState/useRef/useReducer;
  • типы событий формы и кликов;
  • безопасный Context с проверкой на null.
  • Этого достаточно, чтобы код стал заметно стабильнее и проще в поддержке.

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

    Предпочтительный способ — функция с явным типом props (без React.FC):

    // Рекомендуемый способ — явная типизация props:
    interface ButtonProps {
      label: string
      onClick: () => void
      variant?: 'primary' | 'secondary'
      disabled?: boolean
      children: React.ReactNode
    }
    
    function Button({ label, onClick, variant = 'primary', disabled }: ButtonProps) {
      return (
        <button
          className={`btn btn-${variant}`}
          onClick={onClick}
          disabled={disabled}
        >
          {label}
        </button>
      )
    }
    
    // React.FC<Props> — устаревший стиль, имеет нюансы:
    // - неявно добавлял children (исправлено в React 18)
    // - несколько хуже работает с generics
    const Button2: React.FC<ButtonProps> = ({ label, onClick }) => { ... }

    children: React.ReactNode

    interface CardProps {
      title: string
      children: React.ReactNode  // строка, JSX, массив, null — всё подходит
    }
    
    // Альтернативы:
    // React.ReactElement — только JSX элементы
    // JSX.Element        — синоним ReactElement
    // string             — только строки

    Типизация событий

    function Form() {
      // Кнопка
      const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
        e.preventDefault()
        console.log('clicked')
      }
    
      // Инпут
      const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        console.log(e.target.value)
      }
    
      // Форма
      const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault()
      }
    
      return (
        <form onSubmit={handleSubmit}>
          <input onChange={handleChange} />
          <button onClick={handleClick}>Submit</button>
        </form>
      )
    }

    Хуки с типами

    // useState — тип выводится из начального значения
    const [name, setName] = useState('')             // string
    const [count, setCount] = useState(0)            // number
    const [user, setUser] = useState<User | null>(null) // явный тип нужен
    
    // useRef — DOM элемент
    const inputRef = useRef<HTMLInputElement>(null)
    // inputRef.current может быть null до монтирования
    
    // useRef — изменяемое значение (не DOM)
    const timerRef = useRef<number | null>(null)
    timerRef.current = setTimeout(() => {}, 1000)
    
    // useReducer с типами
    type Action =
      | { type: 'increment' }
      | { type: 'decrement' }
      | { type: 'reset'; payload: number }
    
    interface State { count: number }
    
    function reducer(state: State, action: Action): State {
      switch (action.type) {
        case 'increment': return { count: state.count + 1 }
        case 'decrement': return { count: state.count - 1 }
        case 'reset':     return { count: action.payload }
      }
    }
    
    const [state, dispatch] = useReducer(reducer, { count: 0 })
    dispatch({ type: 'reset', payload: 10 })  // payload обязателен!

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

    function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
      const [stored, setStored] = useState<T>(() => {
        try {
          const item = localStorage.getItem(key)
          return item ? JSON.parse(item) : initialValue
        } catch {
          return initialValue
        }
      })
    
      const setValue = (value: T) => {
        setStored(value)
        localStorage.setItem(key, JSON.stringify(value))
      }
    
      return [stored, setValue]
    }
    
    // Использование:
    const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light')

    Context с типами

    interface ThemeContextType {
      theme: 'light' | 'dark'
      toggle: () => void
    }
    
    const ThemeContext = createContext<ThemeContextType | null>(null)
    
    function useTheme(): ThemeContextType {
      const ctx = useContext(ThemeContext)
      if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
      return ctx
    }

    forwardRef

    interface InputProps {
      placeholder?: string
      value: string
      onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
    }
    
    const Input = forwardRef<HTMLInputElement, InputProps>(
      ({ placeholder, value, onChange }, ref) => (
        <input ref={ref} placeholder={placeholder} value={value} onChange={onChange} />
      )
    )

    Частые ошибки в TS + React

    1. Слишком общий тип `any` в начале проекта. Он временно «разблокирует» код, но быстро убивает пользу от TypeScript.

    2. Игнорирование `null` в `useRef` и `useContext`. В React это один из самых частых источников runtime-ошибок.

    3. Смешивание доменной модели и UI-типов. Лучше разделять типы API-данных и типы состояния конкретного компонента.

    Примеры

    React-like система в чистом JS: createElement, useState хук и минимальный рендер — демонстрация как хуки работают внутри

    // Реализуем упрощённый React в чистом JS — чтобы понять
    // как работают хуки и почему TypeScript так важен в React.
    
    // --- Минимальный React-like движок ---
    
    let currentComponent = null
    let hookIndex = 0
    const componentStates = new Map()
    
    function useState(initialValue) {
      const component = currentComponent
      const index = hookIndex++
    
      if (!componentStates.has(component)) {
        componentStates.set(component, [])
      }
    
      const states = componentStates.get(component)
    
      if (states[index] === undefined) {
        states[index] = initialValue
      }
    
      const setState = (newValue) => {
        states[index] = typeof newValue === 'function'
          ? newValue(states[index])
          : newValue
        // В реальном React здесь был бы re-render
        console.log(`  [setState] ${component}.hook[${index}] = ${JSON.stringify(states[index])}`)
      }
    
      return [states[index], setState]
    }
    
    function runComponent(name, componentFn, props = {}) {
      currentComponent = name
      hookIndex = 0
      console.log(`\n--- Рендер компонента: ${name} ---`)
      const result = componentFn(props)
      currentComponent = null
      return result
    }
    
    // --- Компоненты ---
    
    function Counter({ initialCount = 0 }) {
      // TypeScript: useState<number>(initialCount)
      const [count, setCount] = useState(initialCount)
    
      // TypeScript: onClick: React.MouseEventHandler
      const increment = () => setCount(c => c + 1)
      const decrement = () => setCount(c => c - 1)
      const reset = () => setCount(initialCount)
    
      console.log(`  count = ${count}`)
    
      return { count, increment, decrement, reset }
    }
    
    function Form() {
      // TypeScript: useState<string>('')
      const [name, setName] = useState('')
      const [email, setEmail] = useState('')
      const [errors, setErrors] = useState({})
    
      // TypeScript: (e: React.ChangeEvent<HTMLInputElement>) => void
      const handleNameChange = (value) => setName(value)
      const handleEmailChange = (value) => setEmail(value)
    
      const validate = () => {
        const newErrors = {}
        if (!name) newErrors.name = 'Имя обязательно'
        if (!email.includes('@')) newErrors.email = 'Некорректный email'
        setErrors(newErrors)
        return Object.keys(newErrors).length === 0
      }
    
      console.log(`  name="${name}", email="${email}"`)
      return { name, email, errors, handleNameChange, handleEmailChange, validate }
    }
    
    // --- Демонстрация ---
    
    console.log('=== Counter ===')
    const counter = runComponent('Counter', Counter, { initialCount: 5 })
    
    counter.increment()
    counter.increment()
    counter.decrement()
    
    // Второй рендер — состояние сохранено
    const counter2 = runComponent('Counter', Counter, { initialCount: 5 })
    console.log('После двух increment и одного decrement, count =', componentStates.get('Counter')[0])
    
    console.log('\n=== Form ===')
    const form = runComponent('Form', Form)
    form.handleNameChange('Алексей')
    form.handleEmailChange('alex')
    
    const form2 = runComponent('Form', Form)
    console.log('Валидация (email без @):', form2.validate())
    
    form.handleEmailChange('alex@example.com')
    const form3 = runComponent('Form', Form)
    console.log('Валидация (корректный email):', form3.validate())

    TypeScript в React

    Зачем TypeScript в React-проектах

    В маленьком демо React без типов может казаться быстрее, но в реальном проекте без TypeScript начинают накапливаться «тихие» баги: компонент получает не те props, API вернуло неожиданный формат, событие обработано не тем типом элемента. TypeScript решает это на этапе разработки.

    Основная идея: типизировать границы системы. В React это прежде всего props компонентов, результаты API, состояние и обработчики событий. Когда эти точки покрыты типами, большая часть ошибок ловится до запуска приложения.

    Что важно освоить в первую очередь

    Не нужно сразу пытаться типизировать всё на 100%. Начни с базового минимума:

  • интерфейсы props для компонентов;
  • типы для useState/useRef/useReducer;
  • типы событий формы и кликов;
  • безопасный Context с проверкой на null.
  • Этого достаточно, чтобы код стал заметно стабильнее и проще в поддержке.

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

    Предпочтительный способ — функция с явным типом props (без React.FC):

    // Рекомендуемый способ — явная типизация props:
    interface ButtonProps {
      label: string
      onClick: () => void
      variant?: 'primary' | 'secondary'
      disabled?: boolean
      children: React.ReactNode
    }
    
    function Button({ label, onClick, variant = 'primary', disabled }: ButtonProps) {
      return (
        <button
          className={`btn btn-${variant}`}
          onClick={onClick}
          disabled={disabled}
        >
          {label}
        </button>
      )
    }
    
    // React.FC<Props> — устаревший стиль, имеет нюансы:
    // - неявно добавлял children (исправлено в React 18)
    // - несколько хуже работает с generics
    const Button2: React.FC<ButtonProps> = ({ label, onClick }) => { ... }

    children: React.ReactNode

    interface CardProps {
      title: string
      children: React.ReactNode  // строка, JSX, массив, null — всё подходит
    }
    
    // Альтернативы:
    // React.ReactElement — только JSX элементы
    // JSX.Element        — синоним ReactElement
    // string             — только строки

    Типизация событий

    function Form() {
      // Кнопка
      const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
        e.preventDefault()
        console.log('clicked')
      }
    
      // Инпут
      const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        console.log(e.target.value)
      }
    
      // Форма
      const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault()
      }
    
      return (
        <form onSubmit={handleSubmit}>
          <input onChange={handleChange} />
          <button onClick={handleClick}>Submit</button>
        </form>
      )
    }

    Хуки с типами

    // useState — тип выводится из начального значения
    const [name, setName] = useState('')             // string
    const [count, setCount] = useState(0)            // number
    const [user, setUser] = useState<User | null>(null) // явный тип нужен
    
    // useRef — DOM элемент
    const inputRef = useRef<HTMLInputElement>(null)
    // inputRef.current может быть null до монтирования
    
    // useRef — изменяемое значение (не DOM)
    const timerRef = useRef<number | null>(null)
    timerRef.current = setTimeout(() => {}, 1000)
    
    // useReducer с типами
    type Action =
      | { type: 'increment' }
      | { type: 'decrement' }
      | { type: 'reset'; payload: number }
    
    interface State { count: number }
    
    function reducer(state: State, action: Action): State {
      switch (action.type) {
        case 'increment': return { count: state.count + 1 }
        case 'decrement': return { count: state.count - 1 }
        case 'reset':     return { count: action.payload }
      }
    }
    
    const [state, dispatch] = useReducer(reducer, { count: 0 })
    dispatch({ type: 'reset', payload: 10 })  // payload обязателен!

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

    function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
      const [stored, setStored] = useState<T>(() => {
        try {
          const item = localStorage.getItem(key)
          return item ? JSON.parse(item) : initialValue
        } catch {
          return initialValue
        }
      })
    
      const setValue = (value: T) => {
        setStored(value)
        localStorage.setItem(key, JSON.stringify(value))
      }
    
      return [stored, setValue]
    }
    
    // Использование:
    const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light')

    Context с типами

    interface ThemeContextType {
      theme: 'light' | 'dark'
      toggle: () => void
    }
    
    const ThemeContext = createContext<ThemeContextType | null>(null)
    
    function useTheme(): ThemeContextType {
      const ctx = useContext(ThemeContext)
      if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
      return ctx
    }

    forwardRef

    interface InputProps {
      placeholder?: string
      value: string
      onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
    }
    
    const Input = forwardRef<HTMLInputElement, InputProps>(
      ({ placeholder, value, onChange }, ref) => (
        <input ref={ref} placeholder={placeholder} value={value} onChange={onChange} />
      )
    )

    Частые ошибки в TS + React

    1. Слишком общий тип `any` в начале проекта. Он временно «разблокирует» код, но быстро убивает пользу от TypeScript.

    2. Игнорирование `null` в `useRef` и `useContext`. В React это один из самых частых источников runtime-ошибок.

    3. Смешивание доменной модели и UI-типов. Лучше разделять типы API-данных и типы состояния конкретного компонента.

    Примеры

    React-like система в чистом JS: createElement, useState хук и минимальный рендер — демонстрация как хуки работают внутри

    // Реализуем упрощённый React в чистом JS — чтобы понять
    // как работают хуки и почему TypeScript так важен в React.
    
    // --- Минимальный React-like движок ---
    
    let currentComponent = null
    let hookIndex = 0
    const componentStates = new Map()
    
    function useState(initialValue) {
      const component = currentComponent
      const index = hookIndex++
    
      if (!componentStates.has(component)) {
        componentStates.set(component, [])
      }
    
      const states = componentStates.get(component)
    
      if (states[index] === undefined) {
        states[index] = initialValue
      }
    
      const setState = (newValue) => {
        states[index] = typeof newValue === 'function'
          ? newValue(states[index])
          : newValue
        // В реальном React здесь был бы re-render
        console.log(`  [setState] ${component}.hook[${index}] = ${JSON.stringify(states[index])}`)
      }
    
      return [states[index], setState]
    }
    
    function runComponent(name, componentFn, props = {}) {
      currentComponent = name
      hookIndex = 0
      console.log(`\n--- Рендер компонента: ${name} ---`)
      const result = componentFn(props)
      currentComponent = null
      return result
    }
    
    // --- Компоненты ---
    
    function Counter({ initialCount = 0 }) {
      // TypeScript: useState<number>(initialCount)
      const [count, setCount] = useState(initialCount)
    
      // TypeScript: onClick: React.MouseEventHandler
      const increment = () => setCount(c => c + 1)
      const decrement = () => setCount(c => c - 1)
      const reset = () => setCount(initialCount)
    
      console.log(`  count = ${count}`)
    
      return { count, increment, decrement, reset }
    }
    
    function Form() {
      // TypeScript: useState<string>('')
      const [name, setName] = useState('')
      const [email, setEmail] = useState('')
      const [errors, setErrors] = useState({})
    
      // TypeScript: (e: React.ChangeEvent<HTMLInputElement>) => void
      const handleNameChange = (value) => setName(value)
      const handleEmailChange = (value) => setEmail(value)
    
      const validate = () => {
        const newErrors = {}
        if (!name) newErrors.name = 'Имя обязательно'
        if (!email.includes('@')) newErrors.email = 'Некорректный email'
        setErrors(newErrors)
        return Object.keys(newErrors).length === 0
      }
    
      console.log(`  name="${name}", email="${email}"`)
      return { name, email, errors, handleNameChange, handleEmailChange, validate }
    }
    
    // --- Демонстрация ---
    
    console.log('=== Counter ===')
    const counter = runComponent('Counter', Counter, { initialCount: 5 })
    
    counter.increment()
    counter.increment()
    counter.decrement()
    
    // Второй рендер — состояние сохранено
    const counter2 = runComponent('Counter', Counter, { initialCount: 5 })
    console.log('После двух increment и одного decrement, count =', componentStates.get('Counter')[0])
    
    console.log('\n=== Form ===')
    const form = runComponent('Form', Form)
    form.handleNameChange('Алексей')
    form.handleEmailChange('alex')
    
    const form2 = runComponent('Form', Form)
    console.log('Валидация (email без @):', form2.validate())
    
    form.handleEmailChange('alex@example.com')
    const form3 = runComponent('Form', Form)
    console.log('Валидация (корректный email):', form3.validate())

    Задание

    Реализуй типизированный `createEmitter<T>()` — EventEmitter с дженериком для типа данных события. Интерфейс `Emitter<T>`: методы `on(event: string, handler: (data: T) => void): void`, `emit(event: string, data: T): void`, `off(event: string, handler: (data: T) => void): void`. Используй `Map<string, Set<(data: T) => void>>` для хранения обработчиков.

    Подсказка

    Объяви `const listeners: Map<string, Set<EventHandler<T>>> = new Map()`. В `on()`: если нет записи — `listeners.set(event, new Set())`, затем `listeners.get(event)!.add(handler)`. В `off()`: `listeners.get(event)?.delete(handler)`. В `emit()`: `listeners.get(event)?.forEach(h => h(data))`.

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