TypeScript в React: Ошибки типизации состояний и пропсов, и как их избежать

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

1. Состояния с undefined: Кажущаяся опциональность

typescript
const [user, setUser] = useState<User>();

Этот код работает, но таит проблему: TypeScript разрешает undefined даже при включенном strictNullChecks. Для состояния, обязательного после монтирования, лучше явно указать тип:

typescript
const [user, setUser] = useState<User | null>(null);

// При использовании:
if (!user) return <Loader />;
return <Profile data={user} />; // Без ошибок - тип User

Пересечение типов T | null вместо T | undefined сохраняет строгую проверку, особенно когда состояние инициализируется асинхронно.

2. Дети как функция: Типизация render props

Неверное определение типа для компонентов с render props:

typescript
type DropdownProps = {
  children: (isOpen: boolean) => ReactNode;
};

const Dropdown = ({children}: DropdownProps) => {
  const [isOpen, setIsOpen] = useState(false);
  return children(isOpen);
};

Проблема в том, что ReactNode включает string | number | boolean | ..., хотя технически функция возвращает ReactElement. Для точной типизации:

typescript
children: (isOpen: boolean) => JSX.Element;

Но для поддержки массивов и фрагментов:

typescript
children: (isOpen: boolean) => React.ReactElement | React.ReactElement[];

3. События форм: Тип для onSubmit

Обычная ошибка при работе с формами:

typescript
const handleSubmit = (e: Event) => {
  e.preventDefault(); // Ошибка: свойство есть только у React.FormEvent
  // ...
};

Правильный тип зависит от источника события:

typescript
// Нативный элемент формы (не React):
const handleNativeSubmit = (e: SubmitEvent) => { ... };

// React-компонент:
const handleReactSubmit = (e: React.FormEvent<HTMLFormElement>) => { 
  e.preventDefault();
  const email = (e.currentTarget.elements.namedItem('email') as HTMLInputElement).value;
};

Для универсального использования с пользовательскими компонентами:

typescript
type FormSubmitHandler<T extends HTMLElement> = (
  e: React.FormEvent<T>
) => void;

4. Дженерики для асинхронных операций

Типизация API-запросов с параметрами:

typescript
const fetchData = async <T,>(url: string): Promise<T> => {
  const res = await fetch(url);
  return res.json(); // Тип T, но нужна проверка
};

Улучшенная версия с проверкой схемы через Zod:

typescript
const fetchValidatedData = async <T,>(
  url: string, 
  schema: z.ZodSchema<T>
): Promise<T> => {
  const res = await fetch(url);
  const data = await res.json();
  return schema.parse(data); // Выбрасывает ошибку при несоответствии
};

// Использование:
const userSchema = z.object({
  id: z.string(),
  name: z.string(),
});

const user = await fetchValidatedData('/api/user', userSchema);

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

Практические рекомендации

  1. Для состояний компонентов предпочитайте null вместо undefined как стартовое значение
  2. Используйте JSX.Element вместо ReactNode для функциональных детей
  3. Заменяйте any в ивент-хэндлерах на конкретные React.MouseEvent<HTMLButtonElement>
  4. Для сложных API-интерфейсов применяйте дженерики с валидацией схем данных
  5. Всегда включайте strict: true и strictNullChecks в tsconfig.json

Статическая типизация в React — это не про добавление аннотаций «для галочки». Каждый тип должен быть механизмом предупреждения логических ошибок: если приходится использовать @ts-ignore или as any, это сигнал к пересмотру архитектуры компонента, а не к подавлению проверок. Типы создают контракты между частями приложения — чем точнее эти контракты, тем меньше неожиданностей при разработке и рефакторинге.