47. TypeScript + React
TypeScript: Когда React Встречает Железные Типы
Заголовок раздела «TypeScript: Когда React Встречает Железные Типы»Привет, друзья кодеры! Яша снова на связи. Сегодня мы с вами нырнем в пучину прекрасного симбиоза — TypeScript и React. Вы уже освоили дзен типов, дженериков и даже погладили по шерстке декораторы. Отлично! Теперь пришло время применить эти знания в самом популярном UI-фреймворке.
Представьте, что React — это ваш высокопроизводительный гоночный болид. Он быстрый, мощный и позволяет строить невероятные вещи. А TypeScript — это продвинутая система телеметрии и диагностики, которая не только предсказывает поломки до их возникновения, но и помогает вам точно тюнинговать каждую деталь, не боясь что-то сломать. Звучит заманчиво, правда? Погнали!
🏎️ Зачем TypeScript в React?
Заголовок раздела «🏎️ Зачем TypeScript в React?»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}// email="[email protected]"// isActive={true}// onSelectUser={(userId) => console.log(`Выбран пользователь: ${userId}`)}// />Типизация Состояния с useState
Заголовок раздела «Типизация Состояния с useState»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
Заголовок раздела «Типизация Рефов с useRef»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;🚀 Продвинутые Техники
Заголовок раздела «🚀 Продвинутые Техники»Когда базовые вещи освоены, можно переходить к более мощным инструментам.
Типизация useContext
Заголовок раздела «Типизация useContext»Контекст — это способ прокидывать данные глубоко в дерево компонентов без необходимости передавать пропсы на каждом уровне. Его типизация требует особого внимания, особенно при создании.
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[] = [ ];
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;Типизация forwardRef
Заголовок раздела «Типизация forwardRef»Иногда компоненту нужно передать 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;🚨 Типичные Ошибки и Как Их Избежать
Заголовок раздела «🚨 Типичные Ошибки и Как Их Избежать»Даже опытные Яши могут споткнуться! Вот несколько распространенных ловушек:
-
Неправильная типизация начального состояния
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>({ /* начальные данные */ }); -
Пропуск типизации события в обработчиках:
// ОШИБКА: `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 тоже можно получить, если элемент имеет valueconsole.log("Клик по кнопке!");}; -
Использование
anyдля пропсов или возвращаемых значений компонентов: Это убивает все преимущества TypeScript. Старайтесь избегатьanyлюбой ценой, используя дженерики или union-типы, когда это необходимо.// ОШИБКА: Компонент теряет всю проверку типов пропсовconst MyComponent: React.FC<any> = (props) => { /* ... */ };// ПРАВИЛЬНО: Всегда определяйте интерфейс для пропсовinterface MyComponentProps { data: string; }const MyComponentCorrect: React.FC<MyComponentProps> = ({ data }) => { /* ... */ }; -
Проблемы с контекстом (
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.
-
Компонент
Tableс дженериками: Создайте универсальный компонентTable, который принимает массив объектов (data), массив заголовков столбцов (columns), и функцию для рендера каждой ячейки (renderCell).dataдолжен быть дженерик-массивомT[].columnsдолжен быть массивом строк.renderCellдолжен приниматьT(элемент данных) иstring(ключ столбца) и возвращатьReact.ReactNode.- Создайте пример использования с данными
Product(id, name, price) иUser(id, name, email).
-
Пользовательский хук
useFetch: Напишите кастомный хукuseFetch<T>(url: string)для загрузки данных с сервера.- Он должен возвращать
data: T | null,loading: booleanиerror: string | null. - Используйте
useStateдля состояния данных, загрузки и ошибок. - Типизируйте
Tкак дженерик для возвращаемых данных.
- Он должен возвращать
-
Компонент
ControlledFormс обработчиками событий: Создайте компонент формы, которая содержит текстовое поле ввода и кнопку.- Состояние поля ввода должно управляться
useState<string>. - Типизируйте
onChangeдля текстового поля (React.ChangeEvent<HTMLInputElement>). - Типизируйте
onSubmitдля формы (React.FormEvent<HTMLFormElement>). - При отправке формы выводите значение поля в консоль.
- Состояние поля ввода должно управляться
-
Компонент
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 завершено. Удачи в кодинге, Яша верит в вас!