← Курс/TypeScript в React#186 из 257+35 XP

TypeScript в React

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

Предпочтительный способ — функция с явным типом 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} />
  )
)

Примеры

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())