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

React с TypeScript

Зачем TypeScript в React

Вы уже знаете TypeScript. В React он особенно ценен: компоненты принимают пропсы, возвращают JSX, работают с событиями — всё это можно строго типизировать. Результат: автодополнение в IDE, ошибки на этапе компиляции, самодокументирующийся код.

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

Предпочтительный подход — функция с явной типизацией пропсов:

// Объявляем интерфейс для пропсов
interface ButtonProps {
  label: string
  onClick: () => void
  variant?: 'primary' | 'secondary' | 'danger'  // опциональный с union type
  disabled?: boolean
  children?: React.ReactNode  // принимает любой JSX
}

// Явная типизация — предпочтительный стиль
function Button({ label, onClick, variant = 'primary', disabled }: ButtonProps) {
  return (
    <button className={`btn btn-${variant}`} onClick={onClick} disabled={disabled}>
      {label}
    </button>
  )
}

// React.FC — устаревший стиль (не рекомендуется в React 18+):
// неявно добавлял children (исправлено), хуже с generics
const Button2: React.FC<ButtonProps> = ({ label }) => <button>{label}</button>

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

function Form() {
  // Тип события: React.ChangeEvent<HTMLInputElement>
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value)       // строка
    console.log(e.target.checked)     // boolean (для checkbox)
    console.log(e.target.files)       // FileList | null (для file input)
  }

  // Тип для форм: React.FormEvent<HTMLFormElement>
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
  }

  // Тип для кнопок: React.MouseEvent<HTMLButtonElement>
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault()
    console.log(e.currentTarget.dataset.id)
  }

  // Тип для клавиатуры: React.KeyboardEvent<HTMLInputElement>
  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') submitForm()
  }
}

Типизация хуков

// useState: тип выводится из initialValue или явный generic
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)
// ref.current может быть null до монтирования — нужна проверка
inputRef.current?.focus()

// useRef для мутабельного значения (не DOM)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)

// useReducer: типизация action через discriminated union
type Action =
  | { type: 'increment' }
  | { type: 'add'; payload: number }
  | { 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 'add':       return { count: state.count + action.payload }
    case 'reset':     return { count: action.payload }
  }
}

Типизация Context

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

// null как дефолт — защита от использования вне провайдера
const ThemeContext = createContext<ThemeContextType | null>(null)

// Кастомный хук с защитой
function useTheme(): ThemeContextType {
  const ctx = useContext(ThemeContext)
  if (!ctx) throw new Error('useTheme должен использоваться внутри ThemeProvider')
  return ctx
}

Дженерик-компоненты

// Компонент, работающий с любым типом данных
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, i) => (
        <li key={keyExtractor(item)}>{renderItem(item, i)}</li>
      ))}
    </ul>
  )
}

// Использование — TypeScript выводит тип T автоматически
<List
  items={users}
  keyExtractor={(u) => u.id}   // TypeScript знает: u — User
  renderItem={(u) => u.name}
/>

forwardRef с TypeScript

interface InputProps {
  placeholder?: string
  value: string
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}

// forwardRef<ТипЭлемента, ТипПропсов>
const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ placeholder, value, onChange }, ref) => (
    <input ref={ref} placeholder={placeholder} value={value} onChange={onChange} />
  )
)

as const для action types

// Вместо enum — as const (легче, лучше совместимость)
const TODO_ACTIONS = {
  ADD: 'ADD_TODO',
  TOGGLE: 'TOGGLE_TODO',
  DELETE: 'DELETE_TODO',
} as const

type TodoActionType = typeof TODO_ACTIONS[keyof typeof TODO_ACTIONS]
// 'ADD_TODO' | 'TOGGLE_TODO' | 'DELETE_TODO'

Примеры

Типизированные паттерны React на TypeScript: props interface, события, discriminated union для reducer, generic компоненты

// Этот файл демонстрирует TypeScript-паттерны для React.
// Запустить в браузере нельзя, но код показывает реальные типы.

// === 1. Типизация компонента с пропсами ===

// interface ButtonProps {
//   label: string
//   onClick: () => void
//   variant?: 'primary' | 'secondary' | 'danger'
//   icon?: React.ReactNode
// }
//
// function Button({ label, onClick, variant = 'primary', icon }: ButtonProps) {
//   return (
//     <button className={`btn-${variant}`} onClick={onClick}>
//       {icon && <span className="icon">{icon}</span>}
//       {label}
//     </button>
//   )
// }

// Эмулируем проверку типов в чистом JS:
function createTypeSafeComponent(defaultProps) {
  return function render(props) {
    const merged = { ...defaultProps, ...props }

    // Валидация типов в runtime (что делает TypeScript на этапе компиляции)
    const required = ['label', 'onClick']
    required.forEach(key => {
      if (!(key in merged)) throw new Error(`Prop "${key}" обязателен!`)
    })

    const variantOptions = ['primary', 'secondary', 'danger']
    if (merged.variant && !variantOptions.includes(merged.variant)) {
      throw new Error(`variant должен быть одним из: ${variantOptions.join(', ')}`)
    }

    console.log('  Рендер Button:', merged.label, '| variant:', merged.variant)
    return merged
  }
}

const Button = createTypeSafeComponent({ variant: 'primary' })

// === 2. Discriminated Union для reducer ===

// TypeScript:
// type Action =
//   | { type: 'ADD_ITEM';    payload: { id: number; name: string; price: number } }
//   | { type: 'REMOVE_ITEM'; payload: number }
//   | { type: 'SET_QTY';     payload: { id: number; qty: number } }
//   | { type: 'CLEAR' }

// В TypeScript компилятор ЗНАЕТ какой payload у каждого type:
// case 'ADD_ITEM': action.payload.name  -> OK
// case 'REMOVE_ITEM': action.payload.name -> ERROR! payload это number

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      // TypeScript: action.payload: { id, name, price }
      return { ...state, items: [...state.items, { ...action.payload, qty: 1 }] }

    case 'REMOVE_ITEM':
      // TypeScript: action.payload: number (id)
      return { ...state, items: state.items.filter(i => i.id !== action.payload) }

    case 'SET_QTY':
      // TypeScript: action.payload: { id: number, qty: number }
      return {
        ...state,
        items: state.items.map(i =>
          i.id === action.payload.id ? { ...i, qty: action.payload.qty } : i
        )
      }

    case 'CLEAR':
      // TypeScript: action.payload — не существует! Только { type: 'CLEAR' }
      return { ...state, items: [] }

    default:
      return state
  }
}

// === 3. Обобщённая List-функция ===

// TypeScript:
// function createList<T>(keyExtractor: (item: T) => string) {
//   return function render(items: T[], renderItem: (item: T) => string) { ... }
// }

function createList(keyExtractor) {
  return function render(items, renderItem) {
    return items.map(item => ({
      key: keyExtractor(item),
      content: renderItem(item)
    }))
  }
}

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

console.log('=== Проверка типов props ===')
try {
  Button({ label: 'Сохранить', onClick: () => {} })
  Button({ label: 'Удалить', onClick: () => {}, variant: 'danger' })
  Button({ label: 'Ошибка', onClick: () => {}, variant: 'invalid' })  // бросит ошибку
} catch (e) {
  console.log('  TypeScript поймал бы это на этапе компиляции:', e.message)
}

console.log('
=== Reducer с discriminated union ===')
let state = { items: [] }
state = cartReducer(state, { type: 'ADD_ITEM', payload: { id: 1, name: 'Молоко', price: 89 } })
state = cartReducer(state, { type: 'ADD_ITEM', payload: { id: 2, name: 'Хлеб', price: 45 } })
state = cartReducer(state, { type: 'SET_QTY', payload: { id: 1, qty: 3 } })
console.log('  Товаров в корзине:', state.items.length)
console.log('  Молоко qty:', state.items[0].qty)  // 3

console.log('
=== Типизированный список ===')
const renderUsers = createList(user => user.id.toString())
const users = [{ id: 1, name: 'Анна' }, { id: 2, name: 'Борис' }]
const rendered = renderUsers(users, user => `Привет, ${user.name}!`)
rendered.forEach(({ key, content }) => console.log(`  [${key}] ${content}`))

React с TypeScript

Зачем TypeScript в React

Вы уже знаете TypeScript. В React он особенно ценен: компоненты принимают пропсы, возвращают JSX, работают с событиями — всё это можно строго типизировать. Результат: автодополнение в IDE, ошибки на этапе компиляции, самодокументирующийся код.

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

Предпочтительный подход — функция с явной типизацией пропсов:

// Объявляем интерфейс для пропсов
interface ButtonProps {
  label: string
  onClick: () => void
  variant?: 'primary' | 'secondary' | 'danger'  // опциональный с union type
  disabled?: boolean
  children?: React.ReactNode  // принимает любой JSX
}

// Явная типизация — предпочтительный стиль
function Button({ label, onClick, variant = 'primary', disabled }: ButtonProps) {
  return (
    <button className={`btn btn-${variant}`} onClick={onClick} disabled={disabled}>
      {label}
    </button>
  )
}

// React.FC — устаревший стиль (не рекомендуется в React 18+):
// неявно добавлял children (исправлено), хуже с generics
const Button2: React.FC<ButtonProps> = ({ label }) => <button>{label}</button>

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

function Form() {
  // Тип события: React.ChangeEvent<HTMLInputElement>
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value)       // строка
    console.log(e.target.checked)     // boolean (для checkbox)
    console.log(e.target.files)       // FileList | null (для file input)
  }

  // Тип для форм: React.FormEvent<HTMLFormElement>
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
  }

  // Тип для кнопок: React.MouseEvent<HTMLButtonElement>
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault()
    console.log(e.currentTarget.dataset.id)
  }

  // Тип для клавиатуры: React.KeyboardEvent<HTMLInputElement>
  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') submitForm()
  }
}

Типизация хуков

// useState: тип выводится из initialValue или явный generic
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)
// ref.current может быть null до монтирования — нужна проверка
inputRef.current?.focus()

// useRef для мутабельного значения (не DOM)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)

// useReducer: типизация action через discriminated union
type Action =
  | { type: 'increment' }
  | { type: 'add'; payload: number }
  | { 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 'add':       return { count: state.count + action.payload }
    case 'reset':     return { count: action.payload }
  }
}

Типизация Context

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

// null как дефолт — защита от использования вне провайдера
const ThemeContext = createContext<ThemeContextType | null>(null)

// Кастомный хук с защитой
function useTheme(): ThemeContextType {
  const ctx = useContext(ThemeContext)
  if (!ctx) throw new Error('useTheme должен использоваться внутри ThemeProvider')
  return ctx
}

Дженерик-компоненты

// Компонент, работающий с любым типом данных
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, i) => (
        <li key={keyExtractor(item)}>{renderItem(item, i)}</li>
      ))}
    </ul>
  )
}

// Использование — TypeScript выводит тип T автоматически
<List
  items={users}
  keyExtractor={(u) => u.id}   // TypeScript знает: u — User
  renderItem={(u) => u.name}
/>

forwardRef с TypeScript

interface InputProps {
  placeholder?: string
  value: string
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}

// forwardRef<ТипЭлемента, ТипПропсов>
const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ placeholder, value, onChange }, ref) => (
    <input ref={ref} placeholder={placeholder} value={value} onChange={onChange} />
  )
)

as const для action types

// Вместо enum — as const (легче, лучше совместимость)
const TODO_ACTIONS = {
  ADD: 'ADD_TODO',
  TOGGLE: 'TOGGLE_TODO',
  DELETE: 'DELETE_TODO',
} as const

type TodoActionType = typeof TODO_ACTIONS[keyof typeof TODO_ACTIONS]
// 'ADD_TODO' | 'TOGGLE_TODO' | 'DELETE_TODO'

Примеры

Типизированные паттерны React на TypeScript: props interface, события, discriminated union для reducer, generic компоненты

// Этот файл демонстрирует TypeScript-паттерны для React.
// Запустить в браузере нельзя, но код показывает реальные типы.

// === 1. Типизация компонента с пропсами ===

// interface ButtonProps {
//   label: string
//   onClick: () => void
//   variant?: 'primary' | 'secondary' | 'danger'
//   icon?: React.ReactNode
// }
//
// function Button({ label, onClick, variant = 'primary', icon }: ButtonProps) {
//   return (
//     <button className={`btn-${variant}`} onClick={onClick}>
//       {icon && <span className="icon">{icon}</span>}
//       {label}
//     </button>
//   )
// }

// Эмулируем проверку типов в чистом JS:
function createTypeSafeComponent(defaultProps) {
  return function render(props) {
    const merged = { ...defaultProps, ...props }

    // Валидация типов в runtime (что делает TypeScript на этапе компиляции)
    const required = ['label', 'onClick']
    required.forEach(key => {
      if (!(key in merged)) throw new Error(`Prop "${key}" обязателен!`)
    })

    const variantOptions = ['primary', 'secondary', 'danger']
    if (merged.variant && !variantOptions.includes(merged.variant)) {
      throw new Error(`variant должен быть одним из: ${variantOptions.join(', ')}`)
    }

    console.log('  Рендер Button:', merged.label, '| variant:', merged.variant)
    return merged
  }
}

const Button = createTypeSafeComponent({ variant: 'primary' })

// === 2. Discriminated Union для reducer ===

// TypeScript:
// type Action =
//   | { type: 'ADD_ITEM';    payload: { id: number; name: string; price: number } }
//   | { type: 'REMOVE_ITEM'; payload: number }
//   | { type: 'SET_QTY';     payload: { id: number; qty: number } }
//   | { type: 'CLEAR' }

// В TypeScript компилятор ЗНАЕТ какой payload у каждого type:
// case 'ADD_ITEM': action.payload.name  -> OK
// case 'REMOVE_ITEM': action.payload.name -> ERROR! payload это number

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      // TypeScript: action.payload: { id, name, price }
      return { ...state, items: [...state.items, { ...action.payload, qty: 1 }] }

    case 'REMOVE_ITEM':
      // TypeScript: action.payload: number (id)
      return { ...state, items: state.items.filter(i => i.id !== action.payload) }

    case 'SET_QTY':
      // TypeScript: action.payload: { id: number, qty: number }
      return {
        ...state,
        items: state.items.map(i =>
          i.id === action.payload.id ? { ...i, qty: action.payload.qty } : i
        )
      }

    case 'CLEAR':
      // TypeScript: action.payload — не существует! Только { type: 'CLEAR' }
      return { ...state, items: [] }

    default:
      return state
  }
}

// === 3. Обобщённая List-функция ===

// TypeScript:
// function createList<T>(keyExtractor: (item: T) => string) {
//   return function render(items: T[], renderItem: (item: T) => string) { ... }
// }

function createList(keyExtractor) {
  return function render(items, renderItem) {
    return items.map(item => ({
      key: keyExtractor(item),
      content: renderItem(item)
    }))
  }
}

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

console.log('=== Проверка типов props ===')
try {
  Button({ label: 'Сохранить', onClick: () => {} })
  Button({ label: 'Удалить', onClick: () => {}, variant: 'danger' })
  Button({ label: 'Ошибка', onClick: () => {}, variant: 'invalid' })  // бросит ошибку
} catch (e) {
  console.log('  TypeScript поймал бы это на этапе компиляции:', e.message)
}

console.log('
=== Reducer с discriminated union ===')
let state = { items: [] }
state = cartReducer(state, { type: 'ADD_ITEM', payload: { id: 1, name: 'Молоко', price: 89 } })
state = cartReducer(state, { type: 'ADD_ITEM', payload: { id: 2, name: 'Хлеб', price: 45 } })
state = cartReducer(state, { type: 'SET_QTY', payload: { id: 1, qty: 3 } })
console.log('  Товаров в корзине:', state.items.length)
console.log('  Молоко qty:', state.items[0].qty)  // 3

console.log('
=== Типизированный список ===')
const renderUsers = createList(user => user.id.toString())
const users = [{ id: 1, name: 'Анна' }, { id: 2, name: 'Борис' }]
const rendered = renderUsers(users, user => `Привет, ${user.name}!`)
rendered.forEach(({ key, content }) => console.log(`  [${key}] ${content}`))

Задание

Создай компонент ProfileCard принимающий пропсы с явными типами в JSDoc-комментариях (или просто в деструктуризации). Пропсы: name (строка), age (число), role ("admin" | "user" | "moderator"), isActive (булево). Компонент показывает все данные и окрашивает бейдж роли в разные цвета: admin — красный, moderator — оранжевый, user — синий.

Подсказка

roleColors[role] берёт цвет из объекта по ключу. opacity: isActive ? 1 : 0.5 делает неактивный профиль прозрачным. Дмитрий неактивен — передай isActive={false}. Используй {name}, {age}, {role} в разметке.

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