Модуль 11: React + TypeScript
Урок 11.4: Хуки и типизация
1. Основы хуков в React с TypeScript
Хуки — это функции, которые позволяют использовать состояние и другие возможности React без написания классов. При работе с TypeScript правильная типизация хуков обеспечивает безопасность и удобство разработки.
В этом уроке мы подробно разберём все основные хуки React, их типизацию, а также создадим собственные кастомные хуки с полной поддержкой TypeScript.
Встроенные хуки React
React предоставляет несколько встроенных хуков:
useState — управление состоянием компонента
useEffect — выполнение побочных эффектов
useContext — доступ к контексту
useReducer — управление сложным состоянием
useCallback — мемоизация функций
useMemo — мемоизация значений
useRef — доступ к DOM-элементам или сохранение изменяемых значений
useImperativeHandle — кастомизация ref, переданного родителю
2. Типизация useEffect
Хук useEffect позволяет выполнять побочные эффекты в функциональных компонентах. Правильная типизация этого хука важна для корректной работы с асинхронными операциями и очисткой ресурсов.
Базовый синтаксис useEffect
// Эффект без зависимостей (выполняется при монтировании и размонтировании)
useEffect(() => { console.log('Компонент смонтирован'); // Функция очистки (опционально)
return () => { console.log('Компонент размонтирован'); }; }, []); // Пустой массив зависимостей
// Эффект с зависимостями
useEffect(() => { console.log('Значение count изменилось:', count); }, [count]); // Выполняется при изменении count
// Эффект без массива зависимостей (выполняется при каждом рендере)
useEffect(() => { console.log('Компонент обновлён'); });
Типизация асинхронных эффектов
// Типизация асинхронного эффекта для загрузки данных
interface User { id: number; name: string; email: string; } const UserProfile = ({ userId }: { userId: number }) => { const [user, setUser] = useState<User | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { let isMounted = true; const fetchUser = async () => { setLoading(true); setError(null); try { const response = await fetch(`/api/users/${userId}`); if (!response.ok) { throw new Error('Ошибка загрузки пользователя'); } const data: User = await response.json(); if (isMounted) { setUser(data); setLoading(false); } } catch (err) { if (isMounted) { setError( err instanceof Error ? err.message : 'Неизвестная ошибка' ); setLoading(false); } } }; fetchUser(); return () => { isMounted = false; }; }, [userId]); // Зависимость от userId
if (loading) return <div>Загрузка...</div>; if (error) return <div>Ошибка: {error}</div>; if (!user) return null; return ( <div> <h2>{user.name}</h2> <p>{user.email}</p> </div> ); };
Типизация интервала таймера
// Типизация интервала с правильной очисткой
const Countdown = ({ initialValue = 60 }: { initialValue?: number }) => { const [seconds, setSeconds] = useState(initialValue); const intervalRef = useRef<NodeJS.Timeout | null>(null); useEffect(() => { if (seconds > 0) { intervalRef.current = setInterval(() => { setSeconds(prev => prev - 1); }, 1000); } return () => { if (intervalRef.current) { clearInterval(intervalRef.current); } }; }, [seconds]); return <div>Осталось: {seconds} секунд</div>; };
3. Типизация useContext
Хук useContext позволяет подписываться на контекст и получать его значение. Правильная типизация контекста обеспечивает безопасный доступ к данным из любого компонента дерева.
Создание типизированного контекста
// 1. Определяем интерфейс для контекста
interface AuthContextType { user: User | null; isAuthenticated: boolean; login: (email: string, password: string) => Promise<void>; logout: () => void; loading: boolean; } // 2. Создаём контекст с undefined по умолчанию
const AuthContext = createContext<AuthContextType | undefined>(undefined); // 3. Создаём кастомный хук для безопасного доступа к контексту
function useAuth(): AuthContextType { const context = useContext(AuthContext); if (context === undefined) { throw new Error('useAuth должен использоваться внутри AuthProvider'); } return context; } // 4. Создаём провайдер
interface AuthProviderProps { children: React.ReactNode; } export const AuthProvider: React.FC<AuthProviderProps> = ({ children, }) => { const [user, setUser] = useState<User | null>(null); const [loading, setLoading] = useState(true); useEffect(() => { const checkAuth = async () => { try { const response = await fetch('/api/auth/me'); if (response.ok) { const userData: User = await response.json(); setUser(userData); } } catch (err) { console.error(err); } finally { setLoading(false); } }; checkAuth(); }, []); const login = async (email: string, password: string) => { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); if (!response.ok) { throw new Error('Ошибка авторизации'); } const userData: User = await response.json(); setUser(userData); }; const logout = () => { setUser(null); }; return ( <AuthContext.Provider value={{ user, isAuthenticated: !!user, login, logout, loading, }} > {children} </AuthContext.Provider> ); };
Использование контекста в компонентах
// Использование кастомного хука useAuth
const Header = () => { const { user, isAuthenticated, logout } = useAuth(); return ( <header> {isAuthenticated ? ( <div> <span>Привет, {user?.name}</span> <button onClick={logout}>Выйти</button> </div> ) : ( <a href="/login">Войти</a> )} </header> ); }; // Пример с ThemeContext
type Theme = 'light' | 'dark'; interface ThemeContextType { theme: Theme; toggleTheme: () => void; } const ThemeContext = createContext<ThemeContextType | undefined>(undefined); function useTheme(): ThemeContextType { const context = useContext(ThemeContext); if (!context) { throw new Error('useTheme должен использоваться внутри ThemeProvider'); } return context; } export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const [theme, setTheme] = useState<Theme>('light'); const toggleTheme = () => { setTheme(prev => (prev === 'light' ? 'dark' : 'light')); }; return ( <ThemeContext.Provider value={{ theme, toggleTheme }}> {children} </ThemeContext.Provider> ); };
4. Типизация useCallback и useMemo
Хуки useCallback и useMemo используются для мемоизации функций и значений соответственно. Правильная типизация этих хуков помогает избежать лишних перерисовок и оптимизировать производительность.
Типизация useCallback
// useCallback мемоизирует функцию
const SearchInput = ({ onSearch }: { onSearch: (query: string) => void }) => { const [query, setQuery] = useState(''); // Мемоизированная функция поиска
const handleSearch = useCallback( (searchQuery: string) => { onSearch(searchQuery); }, [onSearch] // Зависимости
); return ( <input type="text" value={query} onChange={(e) => { setQuery(e.target.value); handleSearch(e.target.value); }} placeholder="Поиск..." /> ); }; // useCallback сgenerics для переиспользуемых обработчиков
function useDebouncedCallback<T extends (...args: any[]) => any>( callback: T, delay: number ): T { const timeoutRef = useRef<NodeJS.Timeout | null>(null); return useCallback( (...args: Parameters<T>) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { callback(...args); }, delay); }, [callback, delay] ) as T; }
Типизация useMemo
// useMemo мемоизирует вычисляемое значение
const FilteredList = ({ items, filter, }: { items: Item[]; filter: string; }) => { // Мемоизированная фильтрация
const filteredItems = useMemo(() => { return items.filter(item => item.name.toLowerCase().includes(filter.toLowerCase()) ); }, [items, filter]); // Зависимости
// Мемоизированная статистика
const stats = useMemo(() => ({ total: items.length, filtered: filteredItems.length, matchPercentage: items.length > 0 ? (filteredItems.length / items.length) * 100 : 0, }), [items, filteredItems]); return ( <div> <p>Найдено: {stats.filtered} из {stats.total}</p> <ul> {filteredItems.map(item => ( <li key={item.id}>{item.name}</li> ))} </ul> </div> ); };
5. Типизация useRef
Хук useRef создаёт изменяемый ref, который сохраняет своё значение между рендерами и не вызывает перерисовку при изменении. Он используется для доступа к DOM-элементам и сохранения изменяемых значений.
Доступ к DOM-элементам
// Типизация ref для DOM-элементов
const TextInput = () => { const inputRef = useRef<HTMLInputElement>(null); const focusInput = () => { inputRef.current?.focus(); }; return ( <div> <input ref={inputRef} type="text" /> <button onClick={focusInput}>Фокус</button> </div> ); }; // Типизация ref для canvas
const DrawingCanvas = () => { const canvasRef = useRef<HTMLCanvasElement>(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.fillStyle = 'blue'; ctx.fillRect(10, 10, 100, 100); }, []); return <canvas ref={canvasRef} width={200} height={200} />; };
Сохранение изменяемых значений
// Сохранение предыдущего значения
function usePrevious<T>(value: T): T | undefined { const ref = useRef<T | undefined>(undefined); useEffect(() => { ref.current = value; }, [value]); return ref.current; } // Использование:
const Counter = () => { const [count, setCount] = useState(0); const prevCount = usePrevious(count); return ( <div> <p>Текущее: {count}, Предыдущее: {prevCount}</p> <button onClick={() => setCount(count + 1)}>+</button> </div> ); }; // Сохранение таймера
const Stopwatch = () => { const [elapsed, setElapsed] = useState(0); const intervalRef = useRef<NodeJS.Timeout | null>(null); const start = () => { intervalRef.current = setInterval(() => { setElapsed(prev => prev + 1); }, 1000); }; const stop = () => { if (intervalRef.current) { clearInterval(intervalRef.current); } }; return ( <div> <p>{elapsed} секунд</p> <button onClick={start}>Старт</button> <button onClick={stop}>Стоп</button> </div> ); };
6. Создание кастомных хуков
Кастомные хуки позволяют выносить повторяющуюся логику в переиспользуемые функции. При работе с TypeScript кастомные хуки должны быть правильно типизированы.
Хук useFetch
// Универсальный хук для загрузки данных
interface UseFetchResult<T> { data: T | null; loading: boolean; error: string | null; refetch: () => Promise<void>; } function useFetch<T>(url: string): UseFetchResult<T> { const [data, setData] = useState<T | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); const fetchData = useCallback(async () => { setLoading(true); setError(null); try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ошибка: ${response.status}`); } const result: T = await response.json(); setData(result); } catch (err) { setError( err instanceof Error ? err.message : 'Неизвестная ошибка' ); } finally { setLoading(false); } }, [url]); useEffect(() => { fetchData(); }, [fetchData]); return { data, loading, error, refetch: fetchData }; } // Использование:
interface Post { id: number; title: string; body: string; } const PostList = () => { const { data: posts, loading, error } = useFetch<Post[]>('/api/posts'); if (loading) return <div>Загрузка...</div>; if (error) return <div>Ошибка: {error}</div>; if (!posts) return null; return ( <ul> {posts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> ); };
Хук useToggle
// Простой хук для переключения boolean-значения
function useToggle( initialValue: boolean = false ): [boolean, () => void, (value: boolean) => void] { const [value, setValue] = useState(initialValue); const toggle = useCallback(() => { setValue(prev => !prev); }, []); const setValue2 = useCallback((newValue: boolean) => { setValue(newValue); }, []); return [value, toggle, setValue2]; } // Использование:
const Modal = () => { const [isOpen, toggleOpen, setIsOpen] = useToggle(false); return ( <> <button onClick={toggleOpen}>Открыть модальное окно</button> {isOpen && ( <div className="modal"> <p>Содержимое модального окна</p> <button onClick={() => setIsOpen(false)}>Закрыть</button> </div> )} </> ); };
7. Хук useWindowSize и другие утилиты
Давайте создадим ещё несколько полезных кастомных хуков с полной типизацией.
// Хук для отслеживания размера окна
interface WindowDimensions { width: number; height: number; } function useWindowSize(): WindowDimensions { const [dimensions, setDimensions] = useState<WindowDimensions>({ width: window.innerWidth, height: window.innerHeight, }); useEffect(() => { const handleResize = () => { setDimensions({ width: window.innerWidth, height: window.innerHeight, }); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); return dimensions; } // Хук для отслеживания позиции скролла
function useScrollPosition(): number { const [scrollY, setScrollY] = useState(0); useEffect(() => { const handleScroll = () => { setScrollY(window.scrollY); }; window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, []); return scrollY; } // Хук для media query
function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState(() => { if (typeof window !== 'undefined') { return window.matchMedia(query).matches; } return false; }); useEffect(() => { const mediaQuery = window.matchMedia(query); const handleChange = (e: MediaQueryListEvent) => { setMatches(e.matches); }; mediaQuery.addEventListener('change', handleChange); return () => mediaQuery.removeEventListener('change', handleChange); }, [query]); return matches; } // Использование:
const ResponsiveComponent = () => { const { width, height } = useWindowSize(); const isMobile = useMediaQuery('(max-width: 768px)'); const scrollY = useScrollPosition(); return ( <div> <p>Размер: {width}x{height}</p> <p>Мобильный: {isMobile ? 'да' : 'нет'}</p> <p>Позиция скролла: {scrollY}</p> </div> ); };
8. Практическое задание
Выполните следующие задания для закрепления материала:
Задание 1: useLocalStorage
- Создайтеgenerics хук useLocalStorage с полной типизацией
- Добавьте обработку ошибок при чтении/записи
- Реализуйте синхронизацию между вкладками через event storage
- Создайте пример использования с темой и пользователем
Задание 2: useDebounce
- Создайтеgenerics хук useDebounce для отложенного обновления значения
- Добавьте настройку задержки через параметр
- Используйте useRef для хранения таймера
- Создайте пример использования с полем поиска
Задание 3: useClickOutside
- Создайте хук useClickOutside для обработки кликов вне элемента
- Используйтеgenerics ref для поддержки разных типов элементов
- Добавьте обработку клавиши Escape
- Создайте пример с выпадающим меню
9. Итоги урока
В этом уроке мы подробно изучили:
- Основы работы с хуками в React с TypeScript
- Типизацию useEffect для побочных эффектов
- Создание типизированных контекстов с useContext
- Использование useCallback и useMemo для оптимизации
- Типизацию useRef для DOM-элементов и изменяемых значений
- Создание кастомных хуков (useFetch, useToggle, useWindowSize)
Советы и лучшие практики
- Всегда указывайте зависимости в useEffect, useCallback и useMemo
- Используйте кастомные хуки для выноса повторяющейся логики
- Создавайтеgenerics хуки для максимального переиспользования
- Очищайтепобочный эффекты (таймеры, подписки) в функции очистки useEffect
- Избегайте создания новых объектов/функций внутри useEffect без необходимости
В следующем уроке мы разберём типизацию форм и обработку событий в React.
Отличная работа! Вы освоили типизацию хуков в React!
Тест по хукам в React
1 вопрос
Тест по useCallback
1 вопрос
Тест по useContext
1 вопрос