Типизация 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:
Неявно добавляет
children- это вводит в заблуждениеПроблемы с дженериками - generic-компоненты не работают
Сложности с
displayNameдля отладки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 не требует пропс themeHOC для загрузки данных
```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"
}
}Частые ошибки и их решения
Ошибка | Причина | Решение |
|---|---|---|
|
| Используйте |
| Тип не определён или не импортирован | Создайте интерфейс или импортируйте из другого файла |
| Передаёте null туда, где ожидается string | Используйте union: |
| Неправильный импорт компонента | Проверьте импорт: |
| React не установлен или неправильные типы | Установите: |
Заключение
Типизация React-компонентов - это не дополнительная работа, а **неотъемлемая часть профессиональной разработки в 2026 году**. Она превращает JavaScript-библиотеку в самодокументируемый, безопасный и предсказуемый инструмент.
Ключевые выводы:
Всегда типизируйте пропсы - это ваш контракт с другими разработчиками и с вашим будущим "я"
Избегайте
any- используйтеunknown, дженерики или конкретные типыИспользуйте утилитарные типы -
Partial,Pick,Omitэкономят времяТипизируйте хуки - это улучшает переиспользование логики
Используйте discriminated union для сложных состояний вместо множества флагов
Создавайте type guards для безопасной работы с неизвестными данными
Включайте строгие проверки в
tsconfig.jsonПроверяйте типы в CI - автоматизируйте то, что можно автоматизировать
Используйте zod для валидации API-данных
Организуйте типы в проекте по единой структуре
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.jsonTypeScript проверяется в CI/CD
Написаны type guards для сложных типов данных
Использованы discriminated unions для состояний
Все кастомные хуки типизированы
API-запросы валидируются с zod
Типы организованы по единой структуре папок
Использованы utility-типы для трансформации данных
Стили типизированы (styled-components, CSS-модули или Emotion)
Тема типизирована (для CSS-in-JS)
Комментарии
Чтобы оставить комментарий, войдите в аккаунт.