Модуль 11: React + TypeScript
Урок 11.3: Состояние (useState) с типами
1. Основы useState с типизацией
Хук useState — это один из основных хуков React, который позволяет компонентам иметь собственное состояние. При работе с TypeScript правильная типизация состояния критически важна для обеспечения безопасного кода.
В этом уроке мы подробно разберём все аспекты типизации состояния в React-компонентах, от простых случаев до сложных сценариев с вложенными объектами и массивами.
Базовый синтаксис useState
Хук useState принимает начальное значение и возвращает кортеж из двух элементов: текущее значение состояния и функцию для его обновления.
// Простые типы состояния
const [count, setCount] = useState(0); // count имеет тип number
const [name, setName] = useState(''); // name имеет тип string
const [isActive, setIsActive] = useState(false); // isActive имеет тип boolean
const [items, setItems] = useState(null); // items имеет тип null
// Явное указание типа (когда начальное значение не может определить тип)
const [user, setUser] = useState<User | null>(null); const [data, setData] = useState<string[]>([]); const [selectedId, setSelectedId] = useState<number | undefined>(undefined);
TypeScript обычно выводит тип состояния из начального значения. Однако в некоторых случаях необходимо явно указывать тип:
Когда нужно явно указывать тип?
- Когда начальное значение — пустой массив [] (TypeScript не может определить тип элементов)
- Когда начальное значение — null или undefined, а позже будет другое значение
- Когда нужен union-тип (например, string | null)
- Когда используете сложныеgenerics типы
2. Типизация сложных состояний
В реальных приложениях состояние часто содержит сложные структуры данных: объекты, массивы, вложенные структуры. Давайте разберём, как правильно типизировать такие состояния.
Состояние-объект
// Определяем интерфейс для формы
interface LoginFormState { email: string; password: string; rememberMe: boolean; isLoading: boolean; error: string | null; } const LoginForm = () => { // Начальное значение определяет тип состояния
const [formState, setFormState] = useState<LoginFormState>({ email: '', password: '', rememberMe: false, isLoading: false, error: null, }); // Правильное обновление объектного состояния
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => { setFormState(prev => ({ ...prev, email: e.target.value, })); }; // Обновление вложенного свойства
const handleRememberMe = (e: React.ChangeEvent<HTMLInputElement>) => { setFormState(prev => ({ ...prev, rememberMe: e.target.checked, })); }; // Асинхронное обновление с обработкой ошибок
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setFormState(prev => ({ ...prev, isLoading: true, error: null })); try { await login(formState.email, formState.password); } catch (err) { setFormState(prev => ({ ...prev, isLoading: false, error: err instanceof Error ? err.message : 'Произошла ошибка', })); } }; };
Состояние-массив
// Типизация массива объектов
interface Todo { id: number; text: string; completed: boolean; } const [todos, setTodos] = useState<Todo[]>([]); // Добавление нового элемента в массив
const addTodo = (text: string) => { const newTodo: Todo = { id: Date.now(), text, completed: false, }; setTodos(prev => [...prev, newTodo]); }; // Обновление элемента в массиве
const toggleTodo = (id: number) => { setTodos(prev => prev.map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo ) ); }; // Удаление элемента из массива
const removeTodo = (id: number) => { setTodos(prev => prev.filter(todo => todo.id !== id)); }; // Типизация состояния с union-типом
type Status = 'idle' | 'loading' | 'success' | 'error'; const [status, setStatus] = useState<Status>('idle'); // Обработка состояний с narowing
const renderContent = () => { switch (status) { case 'idle': return <div>Начальное состояние</div>; case 'loading': return <div>Загрузка...</div>; case 'success': return <div>Успешно!</div>; case 'error': return <div>Ошибка!</div>; } };
3. Типизация callback-функций в useState
Функции обновления состояния (set-функции) также нуждаются в правильной типизации. Давайте разберём основные паттерны.
Типизация setter-функций
// Простая setter-функция
const [name, setName] = useState(''); // setName принимает string | ((prev: string) => string)
// Setter с предыдущим значением (functional update)
const increment = () => { setCount(prev => prev + 1); // prev имеет тип number
}; // Setter с условным обновлением
const updateName = (newName: string) => { setName(prev => { if (newName.length > 50) { return prev; // Возвращаем предыдущее значение
} return newName; }); }; // Типизация сложной setter-функции
interface UserState { name: string; email: string; settings: { theme: 'light' | 'dark'; notifications: boolean; }; } const [user, setUser] = useState<UserState>({ name: '', email: '', settings: { theme: 'light', notifications: true, }, }); // Обновление вложенного объекта
const toggleTheme = () => { setUser(prev => ({ ...prev, settings: { ...prev.settings, theme: prev.settings.theme === 'light' ? 'dark' : 'light', }, })); }; // Безопасное обновление вложенного массива
interface CartItem { id: number; name: string; quantity: number; } const [cart, setCart] = useState<CartItem[]>([]); const updateQuantity = (id: number, quantity: number) => { setCart(prev => prev.map(item => item.id === id ? { ...item, quantity: Math.max(0, quantity) } : item ) ); };
4. Типизация с useReducer
Хук useReducer является альтернативой useState для управления сложным состоянием. Он особенно полезен, когда состояние содержит множество подстатей или когда логика обновления состояния сложная.
Базовый синтаксис useReducer
// Определяем типы для состояния и действий
interface CounterState { count: number; step: number; } type CounterAction = | { type: 'increment' } | { type: 'decrement' } | { type: 'setStep'; payload: number } | { type: 'reset' }; // Начальное состояние
const initialState: CounterState = { count: 0, step: 1, }; // Редьюсер с полной типизацией
function counterReducer( state: CounterState, action: CounterAction ): CounterState { 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; } } // Использование в компоненте
const Counter = () => { const [state, dispatch] = useReducer(counterReducer, initialState); return ( <div> <p>Счётчик: {state.count}</p> <p>Шаг: {state.step}</p> <button onClick={() => dispatch({ type: 'increment' })}>+</button> <button onClick={() => dispatch({ type: 'decrement' })}>-</button> <button onClick={() => dispatch({ type: 'setStep', payload: 5 })}>Установить шаг 5</button> <button onClick={() => dispatch({ type: 'reset' })}>Сброс</button> </div> ); };
Продвинутый пример: Типизация API-состояния
// Типизация состояния загрузки данных
interface ApiState<T> { data: T | null; loading: boolean; error: string | null; } type ApiAction<T> = | { type: 'FETCH_START' } | { type: 'FETCH_SUCCESS'; payload: T } | { type: 'FETCH_ERROR'; payload: string }; function apiReducer<T>( state: ApiState<T>, action: ApiAction<T> ): ApiState<T> { switch (action.type) { case 'FETCH_START': return { ...state, loading: true, error: null }; case 'FETCH_SUCCESS': return { data: action.payload, loading: false, error: null }; case 'FETCH_ERROR': return { ...state, loading: false, error: action.payload }; default: return state; } } // Кастомный хук для работы с API
function useApi<T>(fetchFn: () => Promise<T>) { const [state, dispatch] = useReducer(apiReducer<T>, { data: null, loading: true, error: null, }); useEffect(() => { const loadData = async () => { dispatch({ type: 'FETCH_START' }); try { const data = await fetchFn(); dispatch({ type: 'FETCH_SUCCESS', payload: data }); } catch (err) { dispatch({ type: 'FETCH_ERROR', payload: err instanceof Error ? err.message : 'Неизвестная ошибка', }); } }; loadData(); }, []); return state; }
5. Типизация useState сgenerics
Generics в useState позволяют создавать более гибкие и переиспользуемые компоненты. Давайте разберём продвинутые техники использованияgenerics.
Кастомный хук сgenerics
// Кастомный хук для формы сgenerics
function useForm<T extends Record<string, any>>( initialValues: T, validate?: (values: T) => Partial<Record<keyof T, string>> ) { const [values, setValues] = useState<T>(initialValues); const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({}); const [isSubmitting, setIsSubmitting] = useState(false); const handleChange = (field: keyof T, value: T[keyof T]) => { setValues(prev => ({ ...prev, [field]: value, })); }; const handleSubmit = async ( onSubmit: (values: T) => Promise<void> ) => { if (validate) { const validationErrors = validate(values); setErrors(validationErrors); if (Object.keys(validationErrors).length > 0) { return; } } setIsSubmitting(true); try { await onSubmit(values); } finally { setIsSubmitting(false); } }; const reset = () => { setValues(initialValues); setErrors({}); }; return { values, errors, isSubmitting, handleChange, handleSubmit, reset, }; } // Использование кастомного хука
interface LoginFormValues { email: string; password: string; } const form = useForm<LoginFormValues>({ email: '', password: '', }); // form.values имеет тип LoginFormValues
// form.handleChange принимает keyof LoginFormValues и значение
6. Типизация localStorage и sessionStorage
Работа с localStorage и sessionStorage — распространённая задача в React-приложениях. Давайте создадим типизированные обёртки для этих API.
// Типизированная обёртка для localStorage
function useLocalStorage<T>( key: string, initialValue: T ): [T, (value: T | (val: T) => T) => void] { const [storedValue, setStoredValue] = useState<T>(() => { try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch (error) { console.error(error); return initialValue; } }); const setValue = (value: T | (val: T) => T) => { try { const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); window.localStorage.setItem(key, JSON.stringify(valueToStore)); } catch (error) { console.error(error); } }; return [storedValue, setValue]; } // Использование:
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light'); const [user, setUser] = useLocalStorage<User | null>('user', null);
7. Типизация состояния формы
Управление формами — одна из самых частых задач в React. Давайте создадим полноценный пример типизированной формы с валидацией.
// Полный пример типизированной формы регистрации
interface RegistrationFormState { username: string; email: string; password: string; confirmPassword: string; acceptTerms: boolean; } interface FormErrors { username?: string; email?: string; password?: string; confirmPassword?: string; acceptTerms?: string; } const RegistrationForm = () => { const [formData, setFormData] = useState<RegistrationFormState>({ username: '', email: '', password: '', confirmPassword: '', acceptTerms: false, }); const [errors, setErrors] = useState<FormErrors>({}); const validate = (): FormErrors => { const newErrors: FormErrors = {}; if (!formData.username.trim()) { newErrors.username = 'Имя пользователя обязательно'; } else if (formData.username.length < 3) { newErrors.username = 'Имя должно быть не менее 3 символов'; } if (!formData.email) { newErrors.email = 'Email обязателен'; } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { newErrors.email = 'Некорректный email'; } if (!formData.password) { newErrors.password = 'Пароль обязателен'; } else if (formData.password.length < 8) { newErrors.password = 'Пароль должен быть не менее 8 символов'; } if (formData.password !== formData.confirmPassword) { newErrors.confirmPassword = 'Пароли не совпадают'; } if (!formData.acceptTerms) { newErrors.acceptTerms = 'Необходимо принять условия'; } return newErrors; }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const validationErrors = validate(); setErrors(validationErrors); if (Object.keys(validationErrors).length === 0) { console.log('Форма валидна:', formData); } }; return ( <form onSubmit={handleSubmit}> {/* Поля формы с типизированными обработчиками */}
</form> ); };
8. Практическое задание
Выполните следующие задания для закрепления материала:
Задание 1: Counter с useReducer
- Создайте интерфейс CounterState с полями: count (number), step (number), history (number[])
- Определите тип CounterAction для действий: increment, decrement, setStep, reset, undo
- Реализуйте редьюсер с полной типизацией
- Создайте компонент Counter с отображением истории
Задание 2: Кастомный хук useForm
- Создайтеgenerics хук useForm, который принимает начальные значения формы
- Добавьте функцию validate, которая принимает функцию валидации
- Реализуйте автоматическую валидацию при изменении полей
- Используйтеgenerics для получения типизированных значений формы
Задание 3: useLocalStorage
- Создайтеgenerics хук useLocalStorage с полной типизацией
- Добавьте обработку ошибок при чтении/записи
- Реализуйте синхронизацию между вкладками через event storage
- Создайте пример использования с темой (light/dark) и пользователем
9. Итоги урока
В этом уроке мы подробно изучили:
- Базовую типизацию useState для примитивных типов
- Типизацию сложных состояний (объекты, массивы, union-типы)
- Типизацию callback-функций в useState
- Использование useReducer для сложного состояния
- generics для создания переиспользуемых хуков
- Типизацию localStorage и sessionStorage
- Полный пример типизированной формы с валидацией
Советы и лучшие практики
- Всегда указывайте типы для useState, когда начальное значение не определяет тип
- Используйте functional update (prev => ...) для обновления состояния на основе предыдущего
- Применяйте useReducer для сложной логики состояния
- Создавайтеgenerics хуки для переиспользования логики
- Избегайте мутации состояния — всегда создавайте новые объекты/массивы
В следующем уроке мы разберём типизацию хуков и создание кастомных хуков с TypeScript.
Отличная работа! Вы освоили типизацию состояния в React!
Тест по useState с типами
1 вопрос
Тест по обновлению состояния
1 вопрос
Тест по useReducer
1 вопрос