Типизация React-компонентов: от новичка до эксперта с примерами 2026

mr. Cooper 1 час назад Технологии
Типизация React-компонентов: от новичка до эксперта с примерами 2026

После прочтения этой статьи вы сможете типизировать любые React-компоненты: от простых пропсов до сложных HOC и дженериков. 80% ошибок TypeScript в React вы будете исправлять до того, как запустите код.

React и TypeScript - это стандарт индустрии в 2026 году. create-react-app объявлен устаревшим, React 19 принёс новые возможности, а сообщество окончательно перешло на функциональные компоненты с хуками. Но многие разработчики используют только базовые типы, боясь заходить в дженерики и сложные HOC. Из-за этого их код становится менее надёжным, чем мог бы быть. В этом руководстве я покажу, как типизировать компоненты правильно, с реальными примерами, частыми ошибками и практическими решениями, которые можно применить прямо сейчас.

Быстрые ответы (для тех, кому нужно сразу):

- Типизация пропсов - это описание контракта компонента через интерфейсы, где каждый пропс имеет строгий тип

- Дженерики в React - это способ создания компонентов, работающих с любым типом данных без потери типобезопасности

- Лучший способ типизации - явное указание типа пропсов без React.FC, с использованием интерфейсов и утилитарных типов

- Главное правило - никогда не использовать any, вместо него применять unknown или дженерики

Сквозной пример: форма регистрации

На протяжении всей статьи мы будем типизировать один компонент - форму регистрации пользователя. Это позволит увидеть, как все паттерны работают вместе в реальном проекте.

//tsx
// Начнём с простого и будем усложнять
interface RegistrationFormData {
  email: string;
  password: string;
  confirmPassword: string;
  name: string;
  age: number;
  agreeToTerms: boolean;
}

// Базовый компонент формы
const RegistrationForm = (): JSX.Element => {
  const [formData, setFormData] = useState<RegistrationFormData>({
    email: '',
    password: '',
    confirmPassword: '',
    name: '',
    age: 0,
    agreeToTerms: false,
  });

  // ... далее будем добавлять функциональность
};

Зачем нужна типизация в React?

Типизация в React - это контракт между компонентами, который гарантирует, что данные передаются правильно, а ошибки отлавливаются на этапе компиляции, а не в продакшене.

Без типизации вы полагаетесь на:

  • Память разработчика

  • Документацию (которую никто не читает)

  • Удачу при рефакторинге

С типизацией вы получаете:

  • Автодополнение в IDE - пишете код быстрее

  • Самодокументируемый код - интерфейсы пропсов говорят сами за себя

  • Безопасный рефакторинг - изменили тип - компилятор показал все места, где нужно поправить

  • Сокращение багов на 15-20% в продакшене

PropTypes vs TypeScript

До появления TypeScript в React использовали PropTypes для проверки типов в рантайме:

//jsx
// PropTypes - проверка в рантайме
import PropTypes from 'prop-types';

const Button = ({ label, onClick, variant }) => {
  return <button onClick={onClick}>{label}</button>;
};

Button.propTypes = {
  label: PropTypes.string.isRequired,
  onClick: PropTypes.func.isRequired,
  variant: PropTypes.oneOf(['primary', 'secondary']),
};

Почему TypeScript лучше:

Характеристика

PropTypes

TypeScript

Проверка

В рантайме

На этапе компиляции

Автодополнение

Нет 

Есть

Производительность

Есть оверхед

Нет оверхеда

Рефакторинг

Опасный

Безопасный

Интеграция с IDE

Минимальная

Полная

Базовые типы React компонентов

В 2026 году стандарт - функциональные компоненты. Классовые компоненты остались только в legacy-коде.

Функциональные компоненты: два подхода

Подход 1: React.FC (устаревающий)

//tsx
interface ButtonProps {
  label: string;
  onClick: () => void;
}

// React.FC неявно добавляет children
const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
  return <button onClick={onClick}>{label}</button>;
};

Подход 2: Явное указание типа (рекомендуемый)

//tsx
interface ButtonProps {
  label: string;
  onClick: () => void;
}

// Явный возвращаемый тип
const Button = ({ label, onClick }: ButtonProps): JSX.Element => {
  return <button onClick={onClick}>{label}</button>;
};

Почему отказываются от React.FC:

  1. Неявно добавляет children - это вводит в заблуждение

  2. Проблемы с дженериками - generic-компоненты не работают

  3. Сложности с displayName для отладки

  4. React.FC не поддерживает defaultProps правильно

Классовые компоненты (для понимания legacy-кода)

//tsx
interface CounterProps {
  initialCount: number;
}

interface CounterState {
  count: number;
}

class Counter extends React.Component<CounterProps, CounterState> {
  constructor(props: CounterProps) {
    super(props);
    this.state = {
      count: props.initialCount,
    };
  }

  render(): JSX.Element {
    return <div>{this.state.count}</div>;
  }
}

Типизация пропсов: от простого к сложному

Типизация пропсов - это основа React-компонентов. Каждый пропс должен иметь свой тип, и каждый тип должен быть максимально конкретным.

Все основные типы пропсов

//tsx
interface User {
  id: string;
  name: string;
  email: string;
  age: number;
  isActive: boolean;
  createdAt: Date;
}

interface ComponentProps {
  // Примитивы
  label: string;
  count: number;
  isVisible: boolean;
  id?: string; // опциональный пропс
  
  // Объекты
  user: User;
  config: {
    theme: 'light' | 'dark';
    language: 'ru' | 'en';
  };
  
  // Массивы
  items: string[];
  users: User[];
  
  // Функции
  onClick: () => void; // ничего не возвращает
  onChange: (value: string) => void; // принимает аргумент
  onSelect: (user: User) => void; // принимает объект
  
  // Union типы (ограниченный набор значений)
  status: 'pending' | 'loading' | 'success' | 'error';
  variant: 'primary' | 'secondary' | 'danger';
  
  // React-специфичные
  children?: React.ReactNode;
  className?: string;
  style?: React.CSSProperties;
}

Расширение пропсов от HTML-элементов

Частая практика - компонент должен принимать все пропсы нативного элемента + свои кастомные.

//tsx
// Кнопка, которая принимает все пропсы HTMLButtonElement
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant: 'primary' | 'secondary';
  isLoading?: boolean;
  icon?: React.ReactNode;
}

const Button = ({ 
  variant, 
  isLoading, 
  icon, 
  children, 
  className,
  disabled,
  ...props // все остальные пропсы идут в button
}: ButtonProps): JSX.Element => {
  const baseClass = `btn btn-${variant}`;
  const combinedClass = `${baseClass} ${className || ''}`;
  
  return (
    <button 
      className={combinedClass}
      disabled={disabled || isLoading}
      {...props}
    >
      {isLoading && <span className="spinner" />}
      {icon && <span className="icon">{icon}</span>}
      {children}
    </button>
  );
};

// Использование - все HTML-атрибуты доступны
<Button 
  variant="primary" 
  onClick={() => console.log('clicked')}
  type="submit"
  aria-label="Submit form"
  isLoading={false}
>
  Submit
</Button>

Распространённые ошибки при типизации пропсов

Ошибка 1: Использование any

//tsx
// ❌ Плохо - any отключает проверку типов
const Component = ({ data }: { data: any }) => { ... }

// ✅ Хорошо - unknown заставляет проверять
const Component = ({ data }: { data: unknown }) => { 
  if (typeof data === 'string') {
    return <div>{data}</div>;
  }
  return null;
}

// ✅ Лучше - дженерик для конкретного типа
const Component = <T extends unknown>({ data }: { data: T }) => { ... }

Ошибка 2: Забывание про children

//tsx
// ❌ Ошибка: компонент может получить children, но тип не позволяет
const Card = ({ title }: { title: string }) => {
  return <div className="card">{/* где children? */}</div>;
};

// ✅ Правильно - явно указываем children
interface CardProps {
  title: string;
  children: React.ReactNode; // обязательный
}

// или опциональный
interface CardProps {
  title: string;
  children?: React.ReactNode;
}

Ошибка 3: Избыточная типизация

//tsx
// ❌ Когда тип очевиден, явное указание избыточно
const [value, setValue] = useState<string>('');

// ✅ TypeScript выведет string автоматически
const [value, setValue] = useState('');

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

Каждое событие в React имеет свой тип из библиотеки @types/react. Использование правильного типа предотвращает ошибки при работе с DOM.

Основные типы событий

//tsx
// 1. Клик
const handleClick = (e: React.MouseEvent<HTMLButtonElement>): void => {
  e.preventDefault(); // предотвращает стандартное поведение
  console.log('Clicked at:', e.clientX, e.clientY);
};

// 2. Изменение ввода
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
  console.log(e.target.value); // значение инпута
  console.log(e.target.checked); // для checkbox
  console.log(e.target.files); // для file input
};

// 3. Отправка формы
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
  e.preventDefault(); // предотвращает перезагрузку страницы
  const formData = new FormData(e.currentTarget);
  const email = formData.get('email') as string;
};

// 4. Клавиатура
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
  if (e.key === 'Enter') {
    console.log('Enter pressed');
  }
  if (e.key === 'Escape') {
    console.log('Escape pressed');
  }
};

// 5. Фокус
const handleFocus = (e: React.FocusEvent<HTMLInputElement>): void => {
  console.log('Focused on:', e.target.name);
};

// 6. Скролл
const handleScroll = (e: React.UIEvent<HTMLDivElement>): void => {
  console.log('Scroll position:', e.currentTarget.scrollTop);
};

Обработка нескольких элементов с одинаковыми событиями

//tsx
const Form = (): JSX.Element => {
  const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>): void => {
    const { name, value } = e.target;
    console.log(name, value);
  };

  return (
    <form>
      <input name="email" onChange={handleChange} />
      <select name="country" onChange={handleChange}>
        <option value="ru">Russia</option>
        <option value="us">USA</option>
      </select>
    </form>
  );
};

Как правильно типизировать кастомные события

//tsx
// Компонент с кастомным событием
interface SelectItem {
  id: string;
  name: string;
}

interface CustomSelectProps {
  items: SelectItem[];
  onSelect: (item: SelectItem) => void; // кастомное событие
}

const CustomSelect = ({ items, onSelect }: CustomSelectProps): JSX.Element => {
  const handleItemClick = (item: SelectItem): void => {
    onSelect(item); // полностью типизировано
  };

  return (
    <div>
      {items.map((item) => (
        <div key={item.id} onClick={() => handleItemClick(item)}>
          {item.name}
        </div>
      ))}
    </div>
  );
};

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

Хуки - это сердце современных React-приложений. Их правильная типизация критична для стабильности и предсказуемости кода.

useState

//tsx
// Простой useState - тип выводится автоматически
const [count, setCount] = useState(0); // TypeScript знает, что это number
const [name, setName] = useState(''); // TypeScript знает, что это string
const [isActive, setIsActive] = useState(false); // TypeScript знает, что это boolean

// Сложные состояния - указываем тип явно
interface User {
  id: string;
  name: string;
  email: string;
  isActive: boolean;
}

// Если начальное значение null, нужно явно указать тип
const [user, setUser] = useState<User | null>(null);

// Обновление с проверкой
const updateUserName = (name: string): void => {
  setUser(prev => prev ? { ...prev, name } : null);
};

// Состояние с объектом
const [formData, setFormData] = useState({
  email: '',
  password: '',
  remember: false,
});

// Частичное обновление
const updateField = <K extends keyof typeof formData>(
  field: K,
  value: typeof formData[K]
): void => {
  setFormData(prev => ({ ...prev, [field]: value }));
};

useEffect

//tsx
// Типизация cleanup-функции
useEffect(() => {
  const timer = setInterval(() => {
    console.log('Tick');
  }, 1000);

  // cleanup-функция должна возвращать void
  return (): void => {
    clearInterval(timer);
  };
}, []); // зависимости - массив, TypeScript проверяет

// Асинхронный запрос
useEffect(() => {
  const fetchData = async (): Promise<void> => {
    try {
      const response = await fetch('/api/users');
      const data: User[] = await response.json();
      setUsers(data);
    } catch (error) {
      console.error('Failed to fetch:', error);
    }
  };

  fetchData();
}, []); // пустые зависимости - выполняется один раз

useReducer

useReducer требует типизации экшенов через discriminated unions (объединения с дискриминатором).

//tsx
// 1. Определяем типы state и action
type State = {
  count: number;
  step: number;
};

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

// 2. Начальное состояние
const initialState: State = {
  count: 0,
  step: 1,
};

// 3. Reducer функция
const 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 initialState;
    default:
      return state; // TypeScript проверит, что все случаи обработаны
  }
};

// 4. Использование
const Counter = (): JSX.Element => {
  const [state, dispatch] = useReducer(reducer, initialState);

  // dispatch полностью типизирован
  const handleIncrement = (): void => {
    dispatch({ type: 'increment' });
  };

  const handleSetStep = (step: number): void => {
    dispatch({ type: 'setStep', payload: step });
  };

  return (
    <div>
      <div>Count: {state.count}</div>
      <div>Step: {state.step}</div>
      <button onClick={handleIncrement}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => handleSetStep(2)}>Set step to 2</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  );
};

useCallback и useMemo

//tsx
// useCallback - мемоизация функций
const handleClick = useCallback((): void => {
  console.log('Clicked with count:', count);
}, [count]); // функция пересоздаётся только при изменении count

const handleSubmit = useCallback(
  (event: React.FormEvent<HTMLFormElement>): void => {
    event.preventDefault();
    console.log('Form submitted');
  },
  []
); // пустые зависимости - функция никогда не пересоздаётся

// useMemo - мемоизация значений
const expensiveValue = useMemo((): number => {
  return heavyCalculation(count);
}, [count]); // пересчитывается только при изменении count

// Мемоизация объекта
const memoizedUser = useMemo((): User => ({
  id: '1',
  name: 'John',
  email: 'john@example.com',
  isActive: true,
}), []); // создаётся один раз

useRef

//tsx
// 1. Для DOM-элементов
const inputRef = useRef<HTMLInputElement>(null);

const focusInput = (): void => {
  inputRef.current?.focus(); // optional chaining, т.к. может быть null
};

// 2. Для хранения значений (без перерендера)
const countRef = useRef<number>(0);
const incrementRef = (): void => {
  countRef.current += 1;
  console.log(countRef.current); // изменение не вызывает перерендер
};

// 3. Для хранения предыдущего значения
const usePrevious = <T>(value: T): T | undefined => {
  const ref = useRef<T>();
  
  useEffect(() => {
    ref.current = value;
  }, [value]);
  
  return ref.current;
};

// Использование
const [count, setCount] = useState(0);
const prevCount = usePrevious(count); // тип - number | undefined

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

Типизация кастомных хуков - это ключ к их переиспользованию в разных частях приложения.

//tsx
// Хук для управления формой
interface UseFormFieldResult<T> {
  value: T;
  onChange: (value: T) => void;
  error: string | null;
  setError: (error: string | null) => void;
  reset: () => void;
}

function useFormField<T>(
  initialValue: T,
  validate?: (value: T) => string | null
): UseFormFieldResult<T> {
  const [value, setValue] = useState<T>(initialValue);
  const [error, setError] = useState<string | null>(null);

  const onChange = (newValue: T): void => {
    setValue(newValue);
    if (validate) {
      setError(validate(newValue));
    }
  };

  const reset = (): void => {
    setValue(initialValue);
    setError(null);
  };

  return { value, onChange, error, setError, reset };
}

// Использование
const emailField = useFormField<string>('', (value) => {
  if (!value) return 'Email обязателен';
  if (!value.includes('@')) return 'Неверный email';
  return null;
});

// Хук для работы с localStorage
function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T) => void, () => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      // Если JSON некорректный, возвращаем initialValue
      console.error('Error parsing localStorage key:', key, error);
      return initialValue;
    }
  });

  const setValue = (value: T): void => {
    try {
      setStoredValue(value);
      localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error(error);
    }
  };

  const removeValue = (): void => {
    try {
      localStorage.removeItem(key);
      setStoredValue(initialValue);
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue, removeValue];
}

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

Контекст - это глобальное состояние для React-компонентов. Его правильная типизация предотвращает ошибки с доступом к данным и упрощает использование в любом месте приложения.

Создание типизированного контекста

//tsx
// 1. Определяем тип контекста
interface AuthContextType {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  isAuthenticated: boolean;
  isLoading: boolean;
}

// 2. Создаём контекст с начальным значением undefined
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);

// 3. Provider компонент
interface AuthProviderProps {
  children: React.ReactNode;
}

const AuthProvider = ({ children }: AuthProviderProps): JSX.Element => {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  const login = async (email: string, password: string): Promise<void> => {
    setIsLoading(true);
    try {
      const response = await api.login(email, password);
      setUser(response.user);
    } finally {
      setIsLoading(false);
    }
  };

  const logout = (): void => {
    setUser(null);
    api.logout();
  };

  const value: AuthContextType = {
    user,
    login,
    logout,
    isAuthenticated: !!user,
    isLoading,
  };

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
};

// 4. Кастомный хук для безопасного доступа к контексту
const useAuth = (): AuthContextType => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
};

// 5. Использование
const UserProfile = (): JSX.Element => {
  const { user, logout, isLoading } = useAuth();
  
  if (isLoading) {
    return <Spinner />;
  }
  
  if (!user) {
    return <LoginForm />;
  }

  return (
    <div>
      <div>Welcome, {user.name}!</div>
      <button onClick={logout}>Logout</button>
    </div>
  );
};

Оптимизация контекста с разделением по доменам

//tsx
// Разделяем контексты для разных доменов
interface ThemeContextType {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

interface SettingsContextType {
  language: 'ru' | 'en';
  setLanguage: (lang: 'ru' | 'en') => void;
  notifications: boolean;
  toggleNotifications: () => void;
}

const ThemeContext = createContext<ThemeContextType>({
  theme: 'light',
  toggleTheme: () => {},
});

const SettingsContext = createContext<SettingsContextType | undefined>(undefined);

// Хуки для безопасного доступа
const useTheme = (): ThemeContextType => useContext(ThemeContext);

const useSettings = (): SettingsContextType => {
  const context = useContext(SettingsContext);
  if (!context) {
    throw new Error('useSettings must be used within SettingsProvider');
  }
  return context;
};

Типизация Refs

Refs позволяют напрямую работать с DOM-элементами и должны быть правильно типизированы для безопасного доступа и избежания ошибок.

useRef для DOM

//tsx
// 1. Простой ref для DOM-элемента
const inputRef = useRef<HTMLInputElement>(null);

// 2. Функция с проверкой на существование
const focusInput = (): void => {
  if (inputRef.current) {
    inputRef.current.focus();
  }
};

// 3. Автоматический фокус при монтировании
useEffect(() => {
  inputRef.current?.focus();
}, []);

// 4. Forward Ref - проброс ref к дочернему компоненту
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label?: string;
  error?: string;
}

const CustomInput = React.forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, className, ...props }, ref): JSX.Element => {
    return (
      <div className={`form-group ${className || ''}`}>
        {label && <label className="form-label">{label}</label>}
        <input ref={ref} className={`form-input ${error ? 'error' : ''}`} {...props} />
        {error && <div className="form-error">{error}</div>}
      </div>
    );
  }
);

// Использование
const Parent = (): JSX.Element => {
  const inputRef = useRef<HTMLInputElement>(null);
  
  const handleSubmit = (): void => {
    if (inputRef.current) {
      console.log(inputRef.current.value);
    }
  };

  return (
    <>
      <CustomInput ref={inputRef} label="Email" placeholder="Enter email" />
      <button onClick={handleSubmit}>Submit</button>
    </>
  );
};

useImperativeHandle

useImperativeHandle позволяет контролировать, какие методы и свойства будут доступны через ref. Это мощный инструмент для создания компонентов с чётким API.

//tsx
// 1. Определяем тип API, который будет доступен через ref
interface CustomInputHandle {
  focus: () => void;
  clear: () => void;
  getValue: () => string;
  setValue: (value: string) => void;
  select: () => void;
}

interface CustomInputProps {
  initialValue?: string;
  placeholder?: string;
  onChange?: (value: string) => void;
}

// 2. Компонент с публичным API
const CustomInput = React.forwardRef<CustomInputHandle, CustomInputProps>(
  ({ initialValue = '', placeholder, onChange }, ref): JSX.Element => {
    const [value, setValue] = useState(initialValue);
    const inputRef = useRef<HTMLInputElement>(null);

    // 3. Определяем, что доступно через ref
    useImperativeHandle(ref, () => ({
      focus: (): void => {
        inputRef.current?.focus();
      },
      clear: (): void => {
        setValue('');
        onChange?.('');
      },
      getValue: (): string => value,
      setValue: (newValue: string): void => {
        setValue(newValue);
        onChange?.(newValue);
      },
      select: (): void => {
        inputRef.current?.select();
      },
    }), [value, onChange]);

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
      const newValue = e.target.value;
      setValue(newValue);
      onChange?.(newValue);
    };

    return (
      <input
        ref={inputRef}
        value={value}
        onChange={handleChange}
        placeholder={placeholder}
        className="custom-input"
      />
    );
  }
);

// 4. Использование
const Parent = (): JSX.Element => {
  const inputRef = useRef<CustomInputHandle>(null);

  const handleClear = (): void => {
    inputRef.current?.clear();
  };

  const handleFocus = (): void => {
    inputRef.current?.focus();
  };

  const handleGetValue = (): void => {
    const value = inputRef.current?.getValue();
    console.log('Current value:', value);
  };

  return (
    <div>
      <CustomInput ref={inputRef} placeholder="Type something..." />
      <div className="button-group">
        <button onClick={handleFocus}>Focus</button>
        <button onClick={handleClear}>Clear</button>
        <button onClick={handleGetValue}>Get Value</button>
      </div>
    </div>
  );
};

Дженерики в React компонентах

Дженерики позволяют создавать переиспользуемые компоненты, которые работают с разными типами данных без потери типобезопасности. Это ключевой паттерн для создания гибких UI-библиотек.

Компонент-список

//tsx
// 1. Определяем пропсы с дженериком
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string | number;
  className?: string;
  emptyMessage?: string;
}

// 2. Создаём generic-компонент
const List = <T extends unknown>({ 
  items, 
  renderItem, 
  keyExtractor, 
  className,
  emptyMessage = 'No items to display'
}: ListProps<T>): JSX.Element => {
  if (items.length === 0) {
    return <div className="empty-state">{emptyMessage}</div>;
  }

  return (
    <ul className={`list ${className || ''}`}>
      {items.map((item, index) => (
        <li key={keyExtractor(item)} className="list-item">
          {renderItem(item, index)}
        </li>
      ))}
    </ul>
  );
};

// 3. Использование с разными типами
interface User {
  id: string;
  name: string;
  age: number;
  email: string;
}

interface Product {
  id: number;
  title: string;
  price: number;
  inStock: boolean;
}

// Список пользователей
const UserList = (): JSX.Element => {
  const users: User[] = [
    { id: '1', name: 'Alice', age: 30, email: 'alice@example.com' },
    { id: '2', name: 'Bob', age: 25, email: 'bob@example.com' },
  ];

  return (
    <List
      items={users}
      keyExtractor={(user) => user.id}
      renderItem={(user) => (
        <div className="user-item">
          <strong>{user.name}</strong>
          <span> - {user.age} years old</span>
          <span className="email">{user.email}</span>
        </div>
      )}
    />
  );
};

// Список продуктов
const ProductList = (): JSX.Element => {
  const products: Product[] = [
    { id: 1, title: 'Laptop', price: 999, inStock: true },
    { id: 2, title: 'Mouse', price: 25, inStock: false },
  ];

  return (
    <List
      items={products}
      keyExtractor={(product) => product.id}
      renderItem={(product) => (
        <div className="product-item">
          <strong>{product.title}</strong>
          <span> - ${product.price}</span>
          <span className={product.inStock ? 'in-stock' : 'out-of-stock'}>
            {product.inStock ? '✅ In stock' : '❌ Out of stock'}
          </span>
        </div>
      )}
    />
  );
};

Компонент-таблица

//tsx
// 1. Определяем колонку для таблицы
// render получает значение по ключу и сам объект
// Тип значения автоматически выводится из accessor
interface Column<T> {
  header: string;
  accessor: keyof T;
  render?: (value: T[keyof T], item: T) => React.ReactNode;
  width?: string | number;
  align?: 'left' | 'center' | 'right';
}

// 2. Пропсы таблицы
interface TableProps<T> {
  data: T[];
  columns: Column<T>[];
  onRowClick?: (item: T) => void;
  className?: string;
  loading?: boolean;
}

// 3. Generic-компонент таблицы
const Table = <T extends Record<string, unknown>>({
  data,
  columns,
  onRowClick,
  className,
  loading = false,
}: TableProps<T>): JSX.Element => {
  if (loading) {
    return <div className="table-loading">Loading...</div>;
  }

  return (
    <table className={`table ${className || ''}`}>
      <thead>
        <tr>
          {columns.map((col) => (
            <th 
              key={String(col.accessor)} 
              style={{ 
                width: col.width, 
                textAlign: col.align || 'left' 
              }}
            >
              {col.header}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((item, idx) => (
          <tr 
            key={idx} 
            onClick={() => onRowClick?.(item)}
            className={onRowClick ? 'clickable' : ''}
          >
            {columns.map((col) => (
              <td 
                key={String(col.accessor)}
                style={{ textAlign: col.align || 'left' }}
              >
                {col.render 
                  ? col.render(item[col.accessor], item)
                  : String(item[col.accessor])
                }
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
};

// 4. Использование
interface Order {
  id: number;
  customer: string;
  total: number;
  status: 'pending' | 'shipped' | 'delivered';
  createdAt: string;
}

const OrdersTable = (): JSX.Element => {
  const orders: Order[] = [
    { id: 1, customer: 'John Doe', total: 150.50, status: 'pending', createdAt: '2026-01-15' },
    { id: 2, customer: 'Jane Smith', total: 75.00, status: 'shipped', createdAt: '2026-01-14' },
  ];

  const columns: Column<Order>[] = [
    { header: 'ID', accessor: 'id', width: 60, align: 'center' },
    { header: 'Customer', accessor: 'customer' },
    { 
      header: 'Total', 
      accessor: 'total',
      render: (value) => `$${Number(value).toFixed(2)}`,
      align: 'right'
    },
    {
      header: 'Status',
      accessor: 'status',
      render: (value) => {
        const statusColors = {
          pending: '#f59e0b',
          shipped: '#3b82f6',
          delivered: '#10b981',
        };
        return (
          <span style={{ color: statusColors[value as keyof typeof statusColors] }}>
            {String(value).toUpperCase()}
          </span>
        );
      }
    },
    { header: 'Date', accessor: 'createdAt' },
  ];

  const handleRowClick = (order: Order): void => {
    console.log('Selected order:', order.id);
  };

  return <Table data={orders} columns={columns} onRowClick={handleRowClick} />;
};

Типизация HOC (Higher-Order Components)

HOC - это функция, которая принимает компонент и возвращает новый компонент с дополнительной логикой. Их типизация требует особого внимания, так как они часто модифицируют пропсы.

Базовый HOC с логированием

//tsx
// 1. HOC для логирования пропсов
const withLogging = <P extends object>(
  WrappedComponent: React.ComponentType<P>
): React.ComponentType<P> => {
  const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';

  // Возвращаем новый компонент
  const WithLogging = (props: P): JSX.Element => {
    console.log(`[${displayName}] rendered with props:`, props);
    return <WrappedComponent {...props} />;
  };

  // Устанавливаем displayName для отладки
  WithLogging.displayName = `WithLogging(${displayName})`;
  
  return WithLogging;
};

// 2. Использование
interface UserCardProps {
  name: string;
  email: string;
  age: number;
}

const UserCard = ({ name, email, age }: UserCardProps): JSX.Element => (
  <div className="user-card">
    <h3>{name}</h3>
    <p>{email}</p>
    <p>Age: {age}</p>
  </div>
);

const UserCardWithLogging = withLogging(UserCard);

// При рендеринге в консоль выведется: [UserCard] rendered with props: { name: '...', email: '...', age: ... }

HOC с дополнительными пропсами

//tsx
// 1. HOC для добавления темы
interface WithThemeProps {
  theme: 'light' | 'dark';
}

const withTheme = <P extends object>(
  WrappedComponent: React.ComponentType<P & WithThemeProps>
): React.ComponentType<Omit<P, keyof WithThemeProps>> => {
  const WithTheme = (props: Omit<P, keyof WithThemeProps>): JSX.Element => {
    // В реальном проекте здесь был бы контекст темы
    const theme = 'light';
    
    // Приводим пропсы к правильному типу
    return <WrappedComponent {...(props as P)} theme={theme} />;
  };

  WithTheme.displayName = `WithTheme(${WrappedComponent.displayName || 'Component'})`;
  return WithTheme;
};

// 2. Использование
interface CardProps {
  title: string;
  description: string;
}

const Card = ({ title, description, theme }: CardProps & WithThemeProps): JSX.Element => (
  <div className={`card card-${theme}`}>
    <h2>{title}</h2>
    <p>{description}</p>
  </div>
);

const ThemedCard = withTheme(Card);
// Теперь ThemedCard не требует пропс theme

HOC для загрузки данных

```tsx
// 1. HOC для загрузки данных из API
interface WithDataProps<T> {
  data: T[];
  loading: boolean;
  error: string | null;
}

const withData = <T, P extends object>(
  WrappedComponent: React.ComponentType<P & WithDataProps<T>>,
  fetchData: () => Promise<T[]>,
  options?: {
    initialData?: T[];
    onError?: (error: Error) => void;
  }
): React.ComponentType<Omit<P, keyof WithDataProps<T>>> => {
  const WithData = (props: Omit<P, keyof WithDataProps<T>>): JSX.Element => {
    const [data, setData] = useState<T[]>(options?.initialData || []);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
      let isMounted = true;

      const loadData = async (): Promise<void> => {
        try {
          setLoading(true);
          const result = await fetchData();
          if (isMounted) {
            setData(result);
            setError(null);
          }
        } catch (err) {
          if (isMounted) {
            const message = err instanceof Error ? err.message : 'Failed to load data';
            setError(message);
            options?.onError?.(err as Error);
          }
        } finally {
          if (isMounted) {
            setLoading(false);
          }
        }
      };

      loadData();

      return (): void => {
        isMounted = false;
      };
    }, []);

    const dataProps: WithDataProps<T> = { data, loading, error };
    return <WrappedComponent {...(props as P)} {...dataProps} />;
  };

  WithData.displayName = `WithData(${WrappedComponent.displayName || 'Component'})`;
  return WithData;
};

// 2. Использование
interface Post {
  id: number;
  title: string;
  body: string;
}

const fetchPosts = async (): Promise<Post[]> => {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  return response.json();
};

interface PostListProps extends WithDataProps<Post> {
  showBody?: boolean;
}

const PostList = ({ data, loading, error, showBody = false }: PostListProps): JSX.Element => {
  if (loading) return <div>Loading posts...</div>;
  if (error) return <div className="error">Error: {error}</div>;

  return (
    <ul>
      {data.map((post) => (
        <li key={post.id}>
          <h3>{post.title}</h3>
          {showBody && <p>{post.body}</p>}
        </li>
      ))}
    </ul>
  );
};

const PostListWithData = withData(PostList, fetchPosts, {
  onError: (error) => console.error('Failed to load posts:', error)
});

// Использование
const App = (): JSX.Element => {
  return <PostListWithData showBody={true} />;
};

Типизация API-запросов с валидацией

Одна из самых частых проблем в реальных проектах - типизация данных, приходящих с бэкенда. Использование библиотек валидации вроде zod делает этот процесс безопасным и удобным.

Базовый пример с zod

tsx
import { z } from 'zod';

// 1. Определяем схему валидации
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2).max(50),
  email: z.string().email(),
  age: z.number().int().positive().max(120),
  isActive: z.boolean().default(true),
  createdAt: z.string().datetime(),
});

// 2. Выводим тип из схемы
type User = z.infer<typeof UserSchema>;

// 3. Типизируем ответ API
interface ApiResponse<T> {
  data: T | null;
  error: string | null;
  status: number;
}

// 4. Функция запроса с валидацией
const fetchUser = async (id: string): Promise<ApiResponse<User>> => {
  try {
    const response = await fetch(`/api/users/${id}`);
    const rawData = await response.json();
    
    // Валидация данных
    const validatedData = UserSchema.parse(rawData);
    
    return {
      data: validatedData,
      error: null,
      status: response.status,
    };
  } catch (error) {
    if (error instanceof z.ZodError) {
      return {
        data: null,
        error: `Validation error: ${error.errors.map(e => e.message).join(', ')}`,
        status: 400,
      };
    }
    return {
      data: null,
      error: error instanceof Error ? error.message : 'Unknown error',
      status: 500,
    };
  }
};

// 5. Использование в компоненте
const UserProfile = ({ userId }: { userId: string }): JSX.Element => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const loadUser = async (): Promise<void> => {
      setLoading(true);
      const result = await fetchUser(userId);
      
      if (result.error) {
        setError(result.error);
      } else {
        setUser(result.data);
      }
      setLoading(false);
    };

    loadUser();
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  if (!user) return <div>User not found</div>;

  return (
    <div>
      <h3>{user.name}</h3>
      <p>Email: {user.email}</p>
      <p>Age: {user.age}</p>
    </div>
  );
};

Кастомный хук для типизированных запросов

//tsx
// 1. Хук для типизированных API-запросов
interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
  refetch: () => Promise<void>;
}

function useFetch<T>(
  url: string,
  schema: z.ZodSchema<T>,
  options?: RequestInit
): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const fetchData = async (): Promise<void> => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(url, options);
      
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const rawData = await response.json();
      const validatedData = schema.parse(rawData);
      
      setData(validatedData);
    } catch (err) {
      if (err instanceof z.ZodError) {
        setError(`Validation error: ${err.errors.map(e => e.message).join(', ')}`);
      } else {
        setError(err instanceof Error ? err.message : 'Unknown error');
      }
      setData(null);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchData();
    // В React 18+ в строгом режиме эффект может вызваться дважды
    // Это нормально, fetchData защищена от дублирования
  }, [url]);

  return { data, loading, error, refetch: fetchData };
}

// 2. Использование
const ProductsPage = (): JSX.Element => {
  const ProductSchema = z.object({
    id: z.number(),
    title: z.string(),
    price: z.number().positive(),
    description: z.string(),
    category: z.string(),
    inStock: z.boolean(),
  });

  type Product = z.infer<typeof ProductSchema>;
  const ProductsSchema = z.array(ProductSchema);

  const { data: products, loading, error, refetch } = useFetch(
    '/api/products',
    ProductsSchema
  );

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} onRetry={refetch} />;
  if (!products) return <div>No products found</div>;

  return (
    <div>
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
};

Типизация стилей (CSS-in-JS)

В реальных проектах 80% компонентов имеют стили, и разработчики часто не знают, как их правильно типизировать. Рассмотрим основные подходы.

Типизация styled-components

//tsx
import styled from 'styled-components';

// 1. Базовые пропсы для стилизации
interface ButtonStyleProps {
  variant: 'primary' | 'secondary' | 'danger';
  $size?: 'small' | 'medium' | 'large'; // $ - transient prop, не попадает в DOM
  $fullWidth?: boolean;
}

// 2. Стилизованный компонент с пропсами
const StyledButton = styled.button<ButtonStyleProps>`
  padding: ${props => {
    switch (props.$size) {
      case 'small': return '8px 16px';
      case 'large': return '16px 32px';
      default: return '12px 24px';
    }
  }};
  
  background: ${props => {
    switch (props.variant) {
      case 'primary': return '#3b82f6';
      case 'secondary': return '#6b7280';
      case 'danger': return '#ef4444';
      default: return '#3b82f6';
    }
  }};
  
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  width: ${props => props.$fullWidth ? '100%' : 'auto'};
  
  &:hover {
    opacity: 0.9;
  }
  
  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
`;

// 3. Компонент с типизированными пропсами
interface ButtonProps extends ButtonStyleProps {
  onClick: () => void;
  children: React.ReactNode;
  disabled?: boolean;
}

const Button = ({ 
  variant, 
  $size = 'medium', 
  $fullWidth = false,
  onClick, 
  children, 
  disabled 
}: ButtonProps): JSX.Element => {
  return (
    <StyledButton
      variant={variant}
      $size={$size}
      $fullWidth={$fullWidth}
      onClick={onClick}
      disabled={disabled}
    >
      {children}
    </StyledButton>
  );
};

Типизация CSS модулей

//tsx
// Button.module.css
// .button {
//   padding: 12px 24px;
//   border-radius: 8px;
// }
// .primary {
//   background: #3b82f6;
// }
// .secondary {
//   background: #6b7280;
// }

// types/styles.d.ts - глобальное объявление для CSS модулей
declare module '*.module.css' {
  const classes: { [key: string]: string };
  export default classes;
}

// Компонент с CSS модулями
import styles from './Button.module.css';

interface ButtonProps {
  variant: 'primary' | 'secondary';
  onClick: () => void;
  children: React.ReactNode;
}

const Button = ({ variant, onClick, children }: ButtonProps): JSX.Element => {
  return (
    <button 
      className={`${styles.button} ${styles[variant]}`}
      onClick={onClick}
    >
      {children}
    </button>
  );
};

Типизация Emotion

//tsx
import styled from '@emotion/styled';
import { css, ThemeProvider as EmotionThemeProvider } from '@emotion/react';

// 1. Определяем тему
interface Theme {
  colors: {
    primary: string;
    secondary: string;
    danger: string;
    background: string;
    text: string;
  };
  spacing: {
    small: string;
    medium: string;
    large: string;
  };
  typography: {
    fontSize: {
      small: string;
      medium: string;
      large: string;
    };
  };
}

// 2. Создаём тему
const theme: Theme = {
  colors: {
    primary: '#3b82f6',
    secondary: '#6b7280',
    danger: '#ef4444',
    background: '#ffffff',
    text: '#1f2937',
  },
  spacing: {
    small: '8px',
    medium: '16px',
    large: '24px',
  },
  typography: {
    fontSize: {
      small: '14px',
      medium: '16px',
      large: '20px',
    },
  },
};

// 3. Стилизованный компонент с использованием темы
interface StyledCardProps {
  variant?: 'primary' | 'secondary';
  $padding?: keyof Theme['spacing'];
}

const StyledCard = styled.div<StyledCardProps>`
  background: ${props => props.theme.colors.background};
  padding: ${props => props.theme.spacing[props.$padding || 'medium']};
  border: 1px solid ${props => {
    switch (props.variant) {
      case 'primary': return props.theme.colors.primary;
      case 'secondary': return props.theme.colors.secondary;
      default: return '#e5e7eb';
    }
  }};
  border-radius: 8px;
  color: ${props => props.theme.colors.text};
`;

// 4. Компонент с кастомными стилями через css prop
interface CardProps {
  title: string;
  children: React.ReactNode;
  variant?: 'primary' | 'secondary';
}

const Card = ({ title, children, variant }: CardProps): JSX.Element => {
  return (
    <StyledCard variant={variant} $padding="large">
      <h2 css={css`
        color: ${theme.colors.primary};
        font-size: ${theme.typography.fontSize.large};
        margin-bottom: ${theme.spacing.medium};
      `}>
        {title}
      </h2>
      <div>{children}</div>
    </StyledCard>
  );
};

// 5. Provider для темы
const App = (): JSX.Element => {
  return (
    <EmotionThemeProvider theme={theme}>
      <Card title="Hello" variant="primary">
        Content
      </Card>
    </EmotionThemeProvider>
  );
};

Типизация Material-UI (MUI)

//tsx
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { Button as MuiButton } from '@mui/material';
import type { Theme } from '@mui/material/styles';

// Расширение темы
declare module '@mui/material/styles' {
  interface Theme {
    custom: {
      borderRadius: {
        small: number;
        large: number;
      };
    };
  }
  interface ThemeOptions {
    custom?: {
      borderRadius?: {
        small?: number;
        large?: number;
      };
    };
  }
}

// Создание типизированной темы
const theme = createTheme({
  palette: {
    primary: {
      main: '#3b82f6',
    },
    secondary: {
      main: '#6b7280',
    },
  },
  custom: {
    borderRadius: {
      small: 4,
      large: 16,
    },
  },
});

// Компонент с типизацией
interface CustomButtonProps {
  variant: 'contained' | 'outlined';
  size: 'small' | 'medium' | 'large';
  fullWidth?: boolean;
  children: React.ReactNode;
}

const CustomButton = ({ 
  variant, 
  size, 
  fullWidth, 
  children 
}: CustomButtonProps): JSX.Element => {
  return (
    <MuiButton
      variant={variant}
      size={size}
      fullWidth={fullWidth}
    >
      {children}
    </MuiButton>
  );
};

Завершение сквозного примера: полная форма регистрации

Теперь, когда мы изучили все паттерны, давайте соберём полную форму регистрации с использованием всех изученных подходов.

//tsx
// 1. Типы для формы
interface RegistrationFormData {
  email: string;
  password: string;
  confirmPassword: string;
  name: string;
  age: number;
  agreeToTerms: boolean;
}

type FormField = keyof RegistrationFormData;

// 2. Кастомный хук для валидации полей
const validateEmail = (value: string): string | null => {
  if (!value) return 'Email обязателен';
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
    return 'Введите корректный email';
  }
  return null;
};

const validatePassword = (value: string): string | null => {
  if (!value) return 'Пароль обязателен';
  if (value.length < 8) return 'Пароль должен содержать минимум 8 символов';
  return null;
};

const validateName = (value: string): string | null => {
  if (!value) return 'Имя обязательно';
  if (value.length < 2) return 'Имя должно содержать минимум 2 символа';
  return null;
};

// 3. Компонент формы с использованием всех паттернов
interface RegistrationFormProps {
  onSuccess?: () => void;
}

const RegistrationForm = ({ onSuccess }: RegistrationFormProps): JSX.Element => {
  // Используем кастомный хук для каждого поля
  const email = useFormField('', validateEmail);
  const password = useFormField('', validatePassword);
  
  // Валидатор confirmPassword использует password.value
  const confirmPassword = useFormField('', (value) => {
    if (!value) return 'Подтверждение пароля обязательно';
    // Проверяем, что password определён и значения совпадают
    if (password.value && value !== password.value) {
      return 'Пароли не совпадают';
    }
    return null;
  });
  
  const name = useFormField('', validateName);
  
  const age = useFormField<number>(18, (value) => {
    if (isNaN(value)) return 'Введите корректное число';
    if (value < 18) return 'Вам должно быть 18 лет или больше';
    if (value > 120) return 'Введите корректный возраст';
    return null;
  });
  
  const agreeToTerms = useFormField(false);

  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitError, setSubmitError] = useState<string | null>(null);

  // 4. Отправка формы с типизированными данными
  const handleSubmit = useCallback(
    async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
      e.preventDefault();
      
      // Проверяем валидность всех полей
      const errors = [
        email.error,
        password.error,
        confirmPassword.error,
        name.error,
        age.error,
      ].filter(Boolean);

      if (errors.length > 0) {
        setSubmitError('Пожалуйста, исправьте ошибки в форме');
        return;
      }

      if (!agreeToTerms.value) {
        setSubmitError('Необходимо согласиться с условиями');
        return;
      }

      setIsSubmitting(true);
      setSubmitError(null);

      try {
        // Формируем данные для отправки
        const formData: RegistrationFormData = {
          email: email.value,
          password: password.value,
          confirmPassword: confirmPassword.value,
          name: name.value,
          age: age.value,
          agreeToTerms: agreeToTerms.value,
        };

        // Отправляем данные на сервер
        const response = await fetch('/api/register', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(formData),
        });

        if (!response.ok) {
          throw new Error('Ошибка регистрации');
        }

        // Успешная регистрация
        console.log('Registration successful:', formData);
        onSuccess?.();
      } catch (error) {
        setSubmitError(error instanceof Error ? error.message : 'Произошла ошибка');
      } finally {
        setIsSubmitting(false);
      }
    },
    [email.value, password.value, confirmPassword.value, name.value, age.value, agreeToTerms.value, onSuccess]
  );

  // 5. Рендер формы
  return (
    <form onSubmit={handleSubmit} className="registration-form">
      <h2>Регистрация</h2>
      
      {/* Поле Email */}
      <div className="form-field">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={email.value}
          onChange={(e) => email.onChange(e.target.value)}
          onBlur={() => email.onChange(email.value)} // триггерим валидацию
          placeholder="example@mail.com"
          className={email.error ? 'error' : ''}
        />
        {email.error && <span className="error-message">{email.error}</span>}
      </div>

      {/* Поле Пароль */}
      <div className="form-field">
        <label htmlFor="password">Пароль</label>
        <input
          id="password"
          type="password"
          value={password.value}
          onChange={(e) => password.onChange(e.target.value)}
          onBlur={() => password.onChange(password.value)}
          className={password.error ? 'error' : ''}
        />
        {password.error && <span className="error-message">{password.error}</span>}
      </div>

      {/* Поле Подтверждение пароля */}
      <div className="form-field">
        <label htmlFor="confirmPassword">Подтверждение пароля</label>
        <input
          id="confirmPassword"
          type="password"
          value={confirmPassword.value}
          onChange={(e) => confirmPassword.onChange(e.target.value)}
          onBlur={() => confirmPassword.onChange(confirmPassword.value)}
          className={confirmPassword.error ? 'error' : ''}
        />
        {confirmPassword.error && (
          <span className="error-message">{confirmPassword.error}</span>
        )}
      </div>

      {/* Поле Имя */}
      <div className="form-field">
        <label htmlFor="name">Имя</label>
        <input
          id="name"
          type="text"
          value={name.value}
          onChange={(e) => name.onChange(e.target.value)}
          onBlur={() => name.onChange(name.value)}
          className={name.error ? 'error' : ''}
        />
        {name.error && <span className="error-message">{name.error}</span>}
      </div>

      {/* Поле Возраст */}
      <div className="form-field">
        <label htmlFor="age">Возраст</label>
        <input
          id="age"
          type="number"
          value={age.value || ''}
          onChange={(e) => {
            const val = e.target.value;
            age.onChange(val === '' ? 0 : Number(val));
          }}
          onBlur={() => age.onChange(age.value)}
          className={age.error ? 'error' : ''}
          min={18}
          max={120}
        />
        {age.error && <span className="error-message">{age.error}</span>}
      </div>

      {/* Checkbox согласия */}
      <div className="form-field checkbox">
        <label>
          <input
            type="checkbox"
            checked={agreeToTerms.value}
            onChange={(e) => agreeToTerms.onChange(e.target.checked)}
          />
          Я согласен с условиями использования
        </label>
        {!agreeToTerms.value && submitError?.includes('условиями') && (
          <span className="error-message">Необходимо согласиться с условиями</span>
        )}
      </div>

      {/* Общая ошибка */}
      {submitError && (
        <div className="form-error submit-error">{submitError}</div>
      )}

      {/* Кнопка отправки */}
      <button 
        type="submit" 
        disabled={isSubmitting}
        className="submit-button"
      >
        {isSubmitting ? 'Регистрация...' : 'Зарегистрироваться'}
      </button>
    </form>
  );
};

// 6. Использование формы в приложении
const App = (): JSX.Element => {
  const [isRegistered, setIsRegistered] = useState(false);

  const handleSuccess = useCallback(() => {
    setIsRegistered(true);
    // Можно перенаправить пользователя или показать сообщение
  }, []);

  if (isRegistered) {
    return (
      <div className="success-page">
        <h2>Регистрация успешна!</h2>
        <p>Проверьте вашу почту для подтверждения.</p>
      </div>
    );
  }

  return (
    <div className="app">
      <RegistrationForm onSuccess={handleSuccess} />
    </div>
  );
};

Организация типов в больших проектах

В масштабных проектах хаос в типах - это главная проблема. Правильная организация типов спасает от циклических зависимостей и упрощает поддержку.

Структура папок

src/
├── types/
│   ├── api/
│   │   ├── requests.ts    // Типы для API-запросов
│   │   └── responses.ts   // Типы для API-ответов
│   ├── entities/
│   │   ├── user.ts        // Тип User
│   │   ├── product.ts     // Тип Product
│   │   └── index.ts       // Экспорт всех сущностей
│   ├── shared/
│   │   ├── pagination.ts  // Типы для пагинации
│   │   ├── error.ts       // Типы для ошибок
│   │   └── common.ts      // Общие утилитарные типы
│   └── index.ts           // Главный экспорт типов
├── features/
│   ├── auth/
│   │   ├── types/
│   │   │   ├── auth.ts    // Локальные типы для фичи auth
│   │   │   └── forms.ts   // Типы для форм
│   │   ├── components/
│   │   ├── hooks/
│   │   └── api/
│   └── dashboard/
│       ├── types/
│       └── components/
└── shared/
    ├── components/
    ├── hooks/
    └── utils/

Пример организации типов

//tsx
// types/entities/user.ts
export interface User {
  id: string;
  name: string;
  email: string;
  age: number;
  isActive: boolean;
  role: UserRole;
  createdAt: Date;
  updatedAt: Date;
}

export type UserRole = 'admin' | 'user' | 'guest';
export type UserStatus = 'active' | 'inactive' | 'suspended';

// types/shared/pagination.ts
export interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
}

export interface PaginationParams {
  page?: number;
  limit?: number;
  sortBy?: string;
  sortOrder?: 'asc' | 'desc';
}

// types/api/requests.ts
import { PaginationParams } from '../shared/pagination';

export interface GetUsersRequest extends PaginationParams {
  search?: string;
  role?: UserRole;
  status?: UserStatus;
}

// types/api/responses.ts
import { User } from '../entities/user';
import { PaginatedResponse } from '../shared/pagination';

export type GetUsersResponse = PaginatedResponse<User>;

// features/auth/types/auth.ts
import { User } from 'types/entities/user';

export interface AuthState {
  user: User | null;
  isLoading: boolean;
  isAuthenticated: boolean;
  token: string | null;
}

export interface LoginCredentials {
  email: string;
  password: string;
}

export interface RegisterData extends Omit<User, 'id' | 'createdAt' | 'updatedAt'> {
  password: string;
  confirmPassword: string;
}

// Утилита для создания типов с Omit и Pick
export type PublicUser = Omit<User, 'createdAt' | 'updatedAt' | 'isActive'>;
export type UserProfileUpdate = Partial<Pick<User, 'name' | 'email' | 'age'>>;

Расширение глобальных типов

//tsx
// types/global.d.ts
import 'react';

declare module 'react' {
  interface CSSProperties {
    '--custom-color'?: string;
    '--animation-duration'?: string;
  }
}

declare global {
  interface Window {
    __APP_CONFIG__: {
      apiUrl: string;
      environment: 'development' | 'production';
      version: string;
    };
  }
}

// Использование
const apiUrl = window.__APP_CONFIG__.apiUrl;
const customStyle: React.CSSProperties = {
  '--custom-color': '#ff0000',
};

Лучшие практики типизации

1. Используйте интерфейсы для пропсов

//tsx
// ✅ Хорошо - интерфейсы лучше расширяются
interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: 'primary' | 'secondary';
}

// ❌ Плохо - типы сложнее расширять
type ButtonProps = {
  label: string;
  onClick: () => void;
  variant?: 'primary' | 'secondary';
}

2. Избегайте any любой ценой

//tsx
// ❌ Плохо - any отключает все проверки
const Component = ({ data }: { data: any }) => { ... }

// ✅ Хорошо - unknown заставляет проверять
const Component = ({ data }: { data: unknown }) => {
  if (typeof data === 'string') {
    return <div>{data}</div>;
  }
  if (Array.isArray(data)) {
    return <div>{data.length} items</div>;
  }
  return null;
}

// ✅ Лучше - используем дженерики
const Component = <T extends unknown>({ data }: { data: T }) => {
  // работаем с data как с неизвестным типом
  return <div>{String(data)}</div>;
}

3. Используйте утилитарные типы

//tsx
interface User {
  id: string;
  name: string;
  email: string;
  password: string;
  createdAt: Date;
  isActive: boolean;
}

// 1. Partial - все поля опциональны
type UserUpdate = Partial<User>;
// { id?: string; name?: string; email?: string; ... }

// 2. Pick - выбираем нужные поля
type UserDisplay = Pick<User, 'name' | 'email'>;
// { name: string; email: string; }

// 3. Omit - исключаем поля
type PublicUser = Omit<User, 'password' | 'createdAt'>;
// { id: string; name: string; email: string; isActive: boolean; }

// 4. Required - все поля обязательны
type RequiredUser = Required<User>;

// 5. Readonly - все поля только для чтения
type ReadonlyUser = Readonly<User>;

// 6. Record - создание маппинга
type UserRole = 'admin' | 'user' | 'guest';
type RolePermissions = Record<UserRole, string[]>;
// { admin: string[]; user: string[]; guest: string[]; }

// 7. Exclude - исключение из union
type UserStatus = 'active' | 'inactive' | 'pending' | 'banned';
type ActiveUserStatus = Exclude<UserStatus, 'banned' | 'inactive'>;
// 'active' | 'pending'

4. Типизируйте кастомные хуки

//tsx
// ✅ Всегда возвращайте явно типизированный объект или кортеж
interface UseCounterResult {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
  setCount: (value: number) => void;
}

function useCounter(initialValue: number = 0): UseCounterResult {
  const [count, setCount] = useState(initialValue);
  
  return {
    count,
    increment: () => setCount(prev => prev + 1),
    decrement: () => setCount(prev => prev - 1),
    reset: () => setCount(initialValue),
    setCount,
  };
}

// Для кортежей (если нужно возвращать массив)
function useToggle(initialValue: boolean = false): [boolean, () => void, (value: boolean) => void] {
  const [value, setValue] = useState(initialValue);
  const toggle = () => setValue(prev => !prev);
  return [value, toggle, setValue];
}

5. Используйте discriminated union для сложных состояний

//tsx
// Вместо множества булевых флагов используем один тип с дискриминатором
type AsyncState<T> = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

// Компонент с использованием
const UsersPage = (): JSX.Element => {
  const [state, setState] = useState<AsyncState<User[]>>({ status: 'idle' });

  const loadUsers = async (): Promise<void> => {
    setState({ status: 'loading' });
    try {
      const data = await fetchUsers();
      setState({ status: 'success', data });
    } catch (error) {
      setState({ status: 'error', error: error instanceof Error ? error.message : 'Unknown error' });
    }
  };

  // TypeScript сужает тип на основе статуса
  switch (state.status) {
    case 'idle':
      return <button onClick={loadUsers}>Load Users</button>;
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserList users={state.data} />;
    case 'error':
      return <ErrorMessage error={state.error} />;
  }
};

6. Используйте type guards для проверки типов

//tsx
// 1. Создаём type guard для проверки типа
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'name' in obj &&
    'email' in obj &&
    typeof (obj as User).id === 'string' &&
    typeof (obj as User).name === 'string' &&
    typeof (obj as User).email === 'string'
  );
}

// 2. Использование в компоненте
const UserProfile = ({ data }: { data: unknown }): JSX.Element => {
  if (!isUser(data)) {
    return <div>Invalid user data</div>;
  }
  
  // TypeScript знает, что data - это User
  return (
    <div>
      <h3>{data.name}</h3>
      <p>{data.email}</p>
    </div>
  );
};

 Отладка типов

Советы по отладке ошибок TypeScript

1. Используйте typeof для проверки типов в рантайме

//tsx
if (typeof value === 'string') {
  // TypeScript знает, что value - это string
  console.log(value.toUpperCase());
}

if (Array.isArray(value)) {
  // TypeScript знает, что value - это массив
  console.log(value.length);
}

2. Используйте instanceof для проверки экземпляров классов

//tsx
if (error instanceof Error) {
  console.log(error.message);
}

3. Используйте // @ts-expect-error с комментарием

//tsx
// @ts-expect-error - пропс устарел в новой версии
<OldComponent legacyProp="value" />

4. Проверяйте типы в CI

//bash
# package.json
{
  "scripts": {
    "type-check": "tsc --noEmit",
    "lint": "eslint . --ext .ts,.tsx"
  }
}

Частые ошибки и их решения

Ошибка

Причина

Решение

Property 'value' does not exist on type 'EventTarget'

e.target слишком общий

Используйте e.currentTarget или приведите тип: (e.target as HTMLInputElement).value

Cannot find name 'User'

Тип не определён или не импортирован

Создайте интерфейс или импортируйте из другого файла

Type 'null' is not assignable to type 'string'

Передаёте null туда, где ожидается string

Используйте union: string \| null или проверяйте перед использованием

JSX element type 'Component' does not have any construct

Неправильный импорт компонента

Проверьте импорт: import Component from './Component' или import * as Component from './Component'

Cannot find module 'react'

React не установлен или неправильные типы

Установите: npm install @types/react @types/react-dom

Заключение

Типизация React-компонентов - это не дополнительная работа, а **неотъемлемая часть профессиональной разработки в 2026 году**. Она превращает JavaScript-библиотеку в самодокументируемый, безопасный и предсказуемый инструмент.

Ключевые выводы:

  1. Всегда типизируйте пропсы - это ваш контракт с другими разработчиками и с вашим будущим "я"

  2. Избегайте any - используйте unknown, дженерики или конкретные типы

  3. Используйте утилитарные типы - Partial, Pick, Omit экономят время

  4. Типизируйте хуки - это улучшает переиспользование логики

  5. Используйте discriminated union для сложных состояний вместо множества флагов

  6. Создавайте type guards для безопасной работы с неизвестными данными

  7. Включайте строгие проверки в tsconfig.json

  8. Проверяйте типы в CI - автоматизируйте то, что можно автоматизировать

  9. Используйте zod для валидации API-данных

  10. Организуйте типы в проекте по единой структуре

FAQ

Вопрос: Нужно ли типизировать все компоненты или только сложные?

Ответ: Типизировать нужно все компоненты, даже простые. Это создаёт культуру качества и предотвращает ошибки в будущем. Кроме того, автодополнение в IDE работает только с типами.

Вопрос: Как типизировать данные от API, которые приходят без типов?

Ответ: Используйте библиотеки валидации вроде zod для runtime-проверки и автоматического вывода типов: const UserSchema = z.object({...}); type User = z.infer<typeof UserSchema>;

Вопрос: Что лучше: интерфейсы или типы для пропсов?

Ответ: Интерфейсы предпочтительнее для пропсов, так как они лучше расширяются и имеют лучшую производительность в IDE. Типы используйте для union-типов и сложных конструкций.

Вопрос: Как типизировать компоненты с динамическими пропсами?

Ответ: Используйте discriminated unions или создавайте отдельные компоненты для разных вариантов использования вместо динамических пропсов.

Вопрос: TypeScript замедляет разработку?

Ответ: На начальном этапе - да, но в долгосрочной перспективе это ускоряет разработку за счёт уменьшения времени на отладку и рефакторинг. Исследования показывают до 30% сокращения времени разработки в больших проектах.

Вопрос: Как обновить типизацию с React 18 до React 19?

Ответ: Основные изменения: новый хук use() для промисов и контекста, поддержка Server Actions, упрощённая работа с refs (больше не нужен forwardRef). Обновите @types/react до последней версии и следуйте миграционному гайду.

Вопрос: Как типизировать компонент с несколькими вариантами рендеринга?

Ответ: Используйте discriminated union для пропсов: type ButtonProps = PrimaryButtonProps | SecondaryButtonProps | DangerButtonProps; - каждый вариант имеет свой набор пропсов.

Вопрос: Нужно ли типизировать defaultProps?

Ответ: В современном React с функциональными компонентами defaultProps устарели. Используйте значения по умолчанию в деструктуризации пропсов: ({ label = 'Click me', variant = 'primary' }: ButtonProps).

Вопрос: Как типизировать styled-components с пропсами?

Ответ: Используйте интерфейс для пропсов стилизации: interface ButtonStyleProps { variant: 'primary'; $size?: 'small' | 'large'; } const StyledButton = styled.button<ButtonStyleProps>.

Вопрос: Как типизировать theme в Emotion или styled-components?

Ответ: Создайте интерфейс Theme и используйте декларацию модуля. Это даст автодополнение props.theme во всех стилизованных компонентах.

Чек-лист для проверки типизации

  • Все компоненты имеют интерфейсы пропсов

  • Все события типизированы React.ChangeEvent, React.MouseEvent и т.д.)

  • Все хуки имеют явные типы возвращаемых значений

  • Контекст имеет защиту от undefined (кастомный хук с проверкой)

  • Все any заменены на unknown или конкретные типы

  • Использованы утилитарные типы Partial, Pick, Omit)

  • Дженерики используются для переиспользуемых компонентов

  • Включены strict-опции в tsconfig.json

  • TypeScript проверяется в CI/CD

  • Написаны type guards для сложных типов данных

  • Использованы discriminated unions для состояний

  • Все кастомные хуки типизированы

  • API-запросы валидируются с zod

  • Типы организованы по единой структуре папок

  • Использованы utility-типы для трансформации данных

  • Стили типизированы (styled-components, CSS-модули или Emotion)

  • Тема типизирована (для CSS-in-JS)

Комментарии

Пока нет комментариев. Будьте первым, кто напишет.

Чтобы оставить комментарий, войдите в аккаунт.

Похожие статьи