Перейти к содержимому

47. TypeScript + React

Привет, друзья кодеры! Яша снова на связи. Сегодня мы с вами нырнем в пучину прекрасного симбиоза — TypeScript и React. Вы уже освоили дзен типов, дженериков и даже погладили по шерстке декораторы. Отлично! Теперь пришло время применить эти знания в самом популярном UI-фреймворке.

Представьте, что React — это ваш высокопроизводительный гоночный болид. Он быстрый, мощный и позволяет строить невероятные вещи. А TypeScript — это продвинутая система телеметрии и диагностики, которая не только предсказывает поломки до их возникновения, но и помогает вам точно тюнинговать каждую деталь, не боясь что-то сломать. Звучит заманчиво, правда? Погнали!

React сам по себе прекрасен, но когда ваш проект начинает расти, а команда увеличивается, уследить за типами данных, передаваемых между компонентами, становится настоящим испытанием. TypeScript приходит на помощь, предоставляя:

  • Безопасность типов: Компилятор поймает большинство ошибок, связанных с типами, еще до того, как вы запустите код в браузере.
  • Улучшенное автодополнение и рефакторинг: IDE становится намного умнее, предлагая вам правильные пропсы, методы и переменные.
  • Чистый и самодокументированный код: Типы служат живой документацией, показывая, что принимает и возвращает ваш компонент или хук.
  • Уверенность в изменениях: Рефакторинг больших кодовых баз становится менее пугающим, потому что TS укажет на все места, которые нужно обновить.

🛠️ Основы Типизации Функциональных Компонентов и Хуков

Заголовок раздела «🛠️ Основы Типизации Функциональных Компонентов и Хуков»

Начнем с самого сердца современного React — функциональных компонентов и хуков.

Компоненты — это строительные блоки. Каждый блок имеет свои характеристики (пропсы), и нам нужно четко их определить.

import React from 'react';
// 1. Определение интерфейса для пропсов
interface UserCardProps {
name: string;
age: number;
email?: string; // Опциональный пропс
isActive: boolean;
onSelectUser: (userId: string) => void; // Пропс-функция
}
// 2. Типизация функционального компонента с использованием интерфейса
// Вместо React.FC<UserCardProps> можно просто (props: UserCardProps)
const UserCard: React.FC<UserCardProps> = ({ name, age, email, isActive, onSelectUser }) => {
const handleSelect = () => {
// В реальном приложении здесь был бы ID пользователя,
// но для примера используем имя
onSelectUser(name);
};
return (
<div style={{ border: '1px solid gray', padding: '10px', margin: '10px' }}>
<h3>{name}</h3>
<p>Возраст: {age}</p>
{email && <p>Email: {email}</p>}
<p>Статус: {isActive ? 'Активен' : 'Неактивен'}</p>
<button onClick={handleSelect}>Выбрать пользователя</button>
</div>
);
};
export default UserCard;
// Пример использования:
// <UserCard
// name="Яша"
// age={30}
// isActive={true}
// onSelectUser={(userId) => console.log(`Выбран пользователь: ${userId}`)}
// />

useState — это наш главный инструмент для управления внутренним состоянием компонента. TypeScript отлично справляется с его типизацией.

import React, { useState } from 'react';
interface Task {
id: string;
title: string;
isCompleted: boolean;
}
const TaskList: React.FC = () => {
// TypeScript часто может вывести тип сам, если начальное значение не null/undefined.
// Здесь infer: Task[]
const [tasks, setTasks] = useState<Task[]>([]);
// Если начальное значение может быть пустым или null, лучше указать явно:
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const addTask = (title: string) => {
const newTask: Task = { id: Date.now().toString(), title, isCompleted: false };
setTasks(prevTasks => [...prevTasks, newTask]);
};
const toggleTaskStatus = (id: string) => {
setTasks(prevTasks =>
prevTasks.map(task =>
task.id === id ? { ...task, isCompleted: !task.isCompleted } : task
)
);
};
const handleSelectTask = (task: Task) => {
setSelectedTask(task);
};
return (
<div>
<h3>Мои Задачи</h3>
<button onClick={() => addTask(`Задача ${tasks.length + 1}`)}>Добавить Задачу</button>
<ul>
{tasks.map(task => (
<li key={task.id} style={{ textDecoration: task.isCompleted ? 'line-through' : 'none' }}>
{task.title}
<button onClick={() => toggleTaskStatus(task.id)}>
{task.isCompleted ? 'Отменить' : 'Завершить'}
</button>
<button onClick={() => handleSelectTask(task)}>Выбрать</button>
</li>
))}
</ul>
{selectedTask && (
<p>Выбрана задача: {selectedTask.title} (ID: {selectedTask.id})</p>
)}
</div>
);
};
export default TaskList;

useRef может использоваться как для ссылок на DOM-элементы, так и для хранения изменяемых значений, которые не вызывают ререндера.

import React, { useRef, useEffect } from 'react';
const TextInputWithFocusButton: React.FC = () => {
// useRef для DOM-элемента: используем тип HTMLInputElement | null
// Начальное значение всегда null для DOM-рефов, пока элемент не смонтирован.
const inputRef = useRef<HTMLInputElement>(null);
// useRef для изменяемого значения: используем generic <T>
const counterRef = useRef<number>(0);
useEffect(() => {
// При монтировании компонента фокусируемся на поле ввода
if (inputRef.current) {
inputRef.current.focus();
}
// Увеличиваем счетчик при каждом рендере (хотя обычно так не делают, это для примера)
counterRef.current++;
console.log('Component rendered. Counter:', counterRef.current);
}); // Зависимости не указаны, поэтому эффект срабатывает при каждом рендере.
const handleButtonClick = () => {
if (inputRef.current) {
alert(`Значение в поле: ${inputRef.current.value}`);
inputRef.current.value = ''; // Очищаем поле
}
};
return (
<div>
<h3>Пример `useRef`</h3>
<input type="text" ref={inputRef} placeholder="Введите текст" />
<button onClick={handleButtonClick}>Показать и Очистить</button>
<p>Счетчик рендеров (useRef): {counterRef.current}</p>
</div>
);
};
export default TextInputWithFocusButton;

Когда базовые вещи освоены, можно переходить к более мощным инструментам.

Контекст — это способ прокидывать данные глубоко в дерево компонентов без необходимости передавать пропсы на каждом уровне. Его типизация требует особого внимания, особенно при создании.

import React, { createContext, useContext, useState, ReactNode } from 'react';
// 1. Определяем тип для данных в контексте
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
// 2. Создаем контекст с явным типом.
// Важно: начальное значение должно соответствовать ThemeContextType,
// либо быть null (но тогда нужно обрабатывать null при использовании).
// Для простоты, здесь используем 'as' для утверждения типа пустого объекта,
// но это требует уверенности, что провайдер всегда будет.
// Более надежный подход: { theme: 'light', toggleTheme: () => {} } как дефолт,
// или выбрасывать ошибку, если контекст используется без провайдера.
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// 3. Создаем Компонент-Провайдер
interface ThemeProviderProps {
children: ReactNode; // Типизируем дочерние элементы
}
const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
// Значение, которое будет доступно всем потребителям контекста
const contextValue: ThemeContextType = { theme, toggleTheme };
return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
};
// 4. Пользовательский хук для удобного доступа к контексту
const useTheme = (): ThemeContextType => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme должен использоваться внутри ThemeProvider');
}
return context;
};
// Пример использования:
const ThemedComponent: React.FC = () => {
const { theme, toggleTheme } = useTheme();
return (
<div style={{ background: theme === 'light' ? '#eee' : '#333', color: theme === 'light' ? '#333' : '#eee', padding: '20px' }}>
<h3>Текущая тема: {theme}</h3>
<button onClick={toggleTheme}>Переключить тему</button>
</div>
);
};
const App: React.FC = () => (
<ThemeProvider>
<ThemedComponent />
{/* Можно добавить еще компоненты, которые используют useTheme */}
</ThemeProvider>
);
export default App;

Представьте, что вы хотите создать компонент, который может отображать список любых данных, но при этом иметь типизированные пропсы. На помощь приходят дженерики!

import React from 'react';
// 1. Определяем интерфейс для пропсов компонента.
// <T> делает компонент generic, позволяя ему работать с любым типом данных.
interface GenericListProps<T> {
items: T[]; // Массив элементов типа T
// Функция-рендер, которая принимает один элемент типа T и возвращает ReactNode
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string | number; // Функция для извлечения ключа
}
// 2. Создаем дженерик-компонент
// <T> после React.FC указывает, что это Generic Functional Component.
const GenericList = <T,>({ items, renderItem, keyExtractor }: GenericListProps<T>) => {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>
{renderItem(item)}
</li>
))}
</ul>
);
};
export default GenericList;
// Пример использования с разными типами данных:
interface Product {
id: string;
name: string;
price: number;
}
interface User {
id: string;
username: string;
email: string;
}
const ProductList: React.FC = () => {
const products: Product[] = [
{ id: 'p1', name: 'Ноутбук', price: 1200 },
{ id: 'p2', name: 'Мышка', price: 25 },
];
return (
<div>
<h3>Список Продуктов</h3>
<GenericList<Product> // Указываем тип для GenericList
items={products}
renderItem={(product) => (
<span>{product.name} - ${product.price}</span>
)}
keyExtractor={(product) => product.id}
/>
</div>
);
};
const UserDisplay: React.FC = () => {
const users: User[] = [
{ id: 'u1', username: 'yasha', email: '[email protected]' },
{ id: 'u2', username: 'coderGirl', email: '[email protected]' },
];
return (
<div>
<h3>Список Пользователей</h3>
<GenericList<User> // И здесь указываем тип
items={users}
renderItem={(user) => (
<div>
<strong>{user.username}</strong> ({user.email})
</div>
)}
keyExtractor={(user) => user.id}
/>
</div>
);
};
// <App /> компонент для демонстрации
// const App: React.FC = () => (
// <>
// <ProductList />
// <UserDisplay />
// </>
// );
// export default App;

Иногда компоненту нужно передать ref к внутреннему DOM-элементу. Для этого используется forwardRef. Типизация здесь может быть немного замороченной, но она логична.

import React, { forwardRef, useRef, useImperativeHandle } from 'react';
// 1. Определяем интерфейс для пропсов компонента, который будет форвардить реф.
interface ButtonProps {
label: string;
onClick: () => void;
}
// 2. Определяем интерфейс для хэндла, который будет доступен через реф.
// Это то, что будет "выставлено наружу" через useImperativeHandle.
interface ButtonRefHandle {
focusButton: () => void;
// Можно добавить другие методы, которые будут доступны из родителя
logMessage: (msg: string) => void;
}
// 3. Создаем компонент с `forwardRef`.
// Первый generic параметр для forwardRef - это тип рефа, который он получает.
// Второй generic параметр - это тип пропсов.
const ForwardedButton = forwardRef<ButtonRefHandle, ButtonProps>(({ label, onClick }, ref) => {
const internalButtonRef = useRef<HTMLButtonElement>(null);
// useImperativeHandle позволяет родителю вызывать определенные методы
// на этом компоненте через его ref.
useImperativeHandle(ref, () => ({
focusButton: () => {
internalButtonRef.current?.focus();
},
logMessage: (msg: string) => {
console.log(`Сообщение из кнопки: ${msg}`);
}
}));
return (
<button ref={internalButtonRef} onClick={onClick} style={{ margin: '5px' }}>
{label}
</button>
);
});
export default ForwardedButton;
// Пример использования:
const ParentComponent: React.FC = () => {
// Типизируем реф, используя ButtonRefHandle
const buttonRef = useRef<ButtonRefHandle>(null);
const handleClick = () => {
alert('Кнопка была нажата!');
};
const handleFocusButtonClick = () => {
// Вызываем метод, доступный через реф
buttonRef.current?.focusButton();
};
const handleLogMessageClick = () => {
buttonRef.current?.logMessage('Привет из родителя!');
};
return (
<div>
<h3>Пример `forwardRef` и `useImperativeHandle`</h3>
<ForwardedButton
ref={buttonRef} // Передаем реф
label="Кликни меня!"
onClick={handleClick}
/>
<button onClick={handleFocusButtonClick}>Фокус на кнопку</button>
<button onClick={handleLogMessageClick}>Отправить сообщение</button>
</div>
);
};
// export default ParentComponent;

Даже опытные Яши могут споткнуться! Вот несколько распространенных ловушек:

  1. Неправильная типизация начального состояния useState:

    // ОШИБКА: TypeScript выведет number | null. Если вы всегда ожидаете number после инициализации,
    // это может быть проблемой. Лучше явно указать тип.
    const [count, setCount] = useState(null);
    // ПРАВИЛЬНО: Явно указываем тип. Если null возможно, то `number | null`.
    const [countCorrect, setCountCorrect] = useState<number | null>(0);
    // Или, если null не ожидается после первого рендера:
    const [user, setUser] = useState<User | undefined>(undefined); // User будет загружен
    // ИЛИ: useState<User>({ /* начальные данные */ });
  2. Пропуск типизации события в обработчиках:

    // ОШИБКА: `e` будет иметь тип `any` по умолчанию в некоторых конфигурациях TS.
    const handleChange = (e) => {
    console.log(e.target.value);
    };
    // ПРАВИЛЬНО: Используйте `React.ChangeEvent<HTMLInputElement>` для инпутов,
    // `React.MouseEvent<HTMLButtonElement>` для кликов и т.д.
    const handleChangeCorrect = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value);
    };
    const handleClickCorrect = (e: React.MouseEvent<HTMLButtonElement>) => {
    // e.currentTarget.value тоже можно получить, если элемент имеет value
    console.log("Клик по кнопке!");
    };
  3. Использование any для пропсов или возвращаемых значений компонентов: Это убивает все преимущества TypeScript. Старайтесь избегать any любой ценой, используя дженерики или union-типы, когда это необходимо.

    // ОШИБКА: Компонент теряет всю проверку типов пропсов
    const MyComponent: React.FC<any> = (props) => { /* ... */ };
    // ПРАВИЛЬНО: Всегда определяйте интерфейс для пропсов
    interface MyComponentProps { data: string; }
    const MyComponentCorrect: React.FC<MyComponentProps> = ({ data }) => { /* ... */ };
  4. Проблемы с контекстом (useContext) и null / undefined: Как показано в примере ThemeProvider, если createContext инициализирован с undefined или null, вы должны обрабатывать это состояние при потреблении контекста (например, проверкой на undefined или выбрасыванием ошибки).

    const MyContext = createContext<MyContextType | undefined>(undefined);
    const useMyContext = () => {
    const context = useContext(MyContext);
    if (context === undefined) {
    throw new Error('useMyContext must be used within a MyContextProvider');
    }
    return context;
    };

Время применить полученные знания на практике! Ваша задача — создать компоненты, которые демонстрируют глубокое понимание типизации в React.

  1. Компонент Table с дженериками: Создайте универсальный компонент Table, который принимает массив объектов (data), массив заголовков столбцов (columns), и функцию для рендера каждой ячейки (renderCell).

    • data должен быть дженерик-массивом T[].
    • columns должен быть массивом строк.
    • renderCell должен принимать T (элемент данных) и string (ключ столбца) и возвращать React.ReactNode.
    • Создайте пример использования с данными Product (id, name, price) и User (id, name, email).
  2. Пользовательский хук useFetch: Напишите кастомный хук useFetch<T>(url: string) для загрузки данных с сервера.

    • Он должен возвращать data: T | null, loading: boolean и error: string | null.
    • Используйте useState для состояния данных, загрузки и ошибок.
    • Типизируйте T как дженерик для возвращаемых данных.
  3. Компонент ControlledForm с обработчиками событий: Создайте компонент формы, которая содержит текстовое поле ввода и кнопку.

    • Состояние поля ввода должно управляться useState<string>.
    • Типизируйте onChange для текстового поля (React.ChangeEvent<HTMLInputElement>).
    • Типизируйте onSubmit для формы (React.FormEvent<HTMLFormElement>).
    • При отправке формы выводите значение поля в консоль.
  4. Компонент Modal с forwardRef и useImperativeHandle: Создайте компонент Modal, который может быть открыт и закрыт из родительского компонента через ref.

    • Modal должен принимать пропс title: string и children: React.ReactNode.
    • Используйте forwardRef и useImperativeHandle, чтобы предоставить методы open() и close() родителю.
    • Внутри модального окна используйте useState<boolean> для управления видимостью.
    • Родительский компонент должен иметь кнопку “Открыть модальное окно”, которая вызывает open() через ref.

Не боритесь с системой типов. Если TypeScript жалуется, чаще всего он прав. Постарайтесь понять, почему он указывает на ошибку, вместо того чтобы сразу использовать any или @ts-ignore. Это может быть неудобно в начале, но в долгосрочной перспективе сэкономит вам часы отладки и сделает ваш код надежнее и понятнее. Используйте возможности TypeScript на полную, и ваш React-код засияет новыми красками!

На этом наш погружение в TypeScript + React завершено. Удачи в кодинге, Яша верит в вас!