React Hooks преобразили подход к написанию React-компонентов, сделав доступными возможности state и lifecycle в функциональных компонентах. Глубокое понимание hooks — не только useState и useEffect — открывает мощные паттерны для создания поддерживаемых React-приложений. В этом руководстве рассматривается освоение React Hooks с точки зрения senior-разработчика.
Почему Hooks важны
Hooks произвели революцию в разработке на React:
- Более простые компоненты: больше не нужен шаблонный код классов
- Переиспользование логики: custom hooks позволяют делиться логикой со state
- Композиция: объединяйте небольшие hooks в сложное поведение
- Тестируемость: hooks проще тестировать, чем lifecycle-методы
- TypeScript: более качественный вывод типов, чем у class components
Базовые Hooks
useState: Состояние компонента
import { useState } from 'react';
function Counter() {
// Базовое использование
const [count, setCount] = useState(0);
// Ленивая инициализация (дорогостоящее вычисление)
const [data, setData] = useState(() => {
return computeExpensiveInitialValue();
});
// Состояние-объект
const [user, setUser] = useState({ name: '', email: '' });
// Обновление через callback (доступ к предыдущему значению)
const increment = () => {
setCount(prev => prev + 1);
};
// Обновление объекта (spread, чтобы сохранить остальные поля)
const updateName = (name) => {
setUser(prev => ({ ...prev, name }));
};
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
<input
value={user.name}
onChange={(e) => updateName(e.target.value)}
/>
</div>
);
}
useEffect: Побочные эффекты
import { useEffect, useState } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// Запуск при каждом рендере (редко требуется)
useEffect(() => {
console.log('Component rendered');
});
// Запуск один раз при монтировании
useEffect(() => {
console.log('Component mounted');
// Очистка при размонтировании
return () => {
console.log('Component unmounted');
};
}, []);
// Запуск при изменении зависимости
useEffect(() => {
let cancelled = false;
async function fetchUser() {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
// Предотвратить обновление state, если компонент размонтирован
if (!cancelled) {
setUser(data);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchUser();
// Очистка: отменить ожидающий запрос
return () => {
cancelled = true;
};
}, [userId]); // Повторный запуск при изменении userId
if (loading) return <div>Loading...</div>;
return <div>{user?.name}</div>;
}
useContext: Общее состояние
import { createContext, useContext, useState } from 'react';
// Создать context
const ThemeContext = createContext(null);
// Компонент Provider
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// Custom hook для использования context
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// Использование
function ThemedButton() {
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
style={{
background: theme === 'light' ? '#fff' : '#333',
color: theme === 'light' ? '#333' : '#fff'
}}
>
Toggle Theme
</button>
);
}
useReducer: Сложное состояние
import { useReducer } from 'react';
// Функция reducer
function todoReducer(state, action) {
switch (action.type) {
case 'ADD':
return [...state, { id: Date.now(), text: action.text, done: false }];
case 'TOGGLE':
return state.map(todo =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
);
case 'DELETE':
return state.filter(todo => todo.id !== action.id);
case 'CLEAR_COMPLETED':
return state.filter(todo => !todo.done);
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function TodoList() {
const [todos, dispatch] = useReducer(todoReducer, []);
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim()) {
dispatch({ type: 'ADD', text });
setText('');
}
};
return (
<div>
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={() => dispatch({ type: 'TOGGLE', id: todo.id })}
/>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => dispatch({ type: 'DELETE', id: todo.id })}>
Delete
</button>
</li>
))}
</ul>
<button onClick={() => dispatch({ type: 'CLEAR_COMPLETED' })}>
Clear Completed
</button>
</div>
);
}
Hooks для производительности
useMemo: Мемоизация значений
import { useMemo, useState } from 'react';
function ExpensiveList({ items, filter }) {
// Пересчитывать только при изменении items или filter
const filteredItems = useMemo(() => {
console.log('Filtering items...');
return items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
}, [items, filter]);
// Мемоизировать сложные объекты для props дочерних компонентов
const sortedItems = useMemo(() => {
return [...filteredItems].sort((a, b) => a.name.localeCompare(b.name));
}, [filteredItems]);
return (
<ul>
{sortedItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
useCallback: Мемоизация функций
import { useCallback, useState, memo } from 'react';
// Мемоизированный дочерний компонент
const Button = memo(function Button({ onClick, label }) {
console.log(`Rendering ${label}`);
return <button onClick={onClick}>{label}</button>;
});
function ParentComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// Без useCallback это создаёт новую функцию при каждом рендере
// const handleClick = () => setCount(c => c + 1);
// С useCallback ссылка на функцию остаётся стабильной
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
// Если callback нужен текущий state, добавьте его в зависимости
const handleReset = useCallback(() => {
setCount(0);
setName('');
}, []);
return (
<div>
<p>Count: {count}</p>
<input value={name} onChange={(e) => setName(e.target.value)} />
{/*Button не будет перерендериваться при изменении name*/}
<Button onClick={handleClick} label="Increment" />
<Button onClick={handleReset} label="Reset" />
</div>
);
}
useRef: Изменяемая ссылка
import { useRef, useEffect, useState } from 'react';
function FocusInput() {
const inputRef = useRef(null);
// Фокус при монтировании
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} />;
}
function Timer() {
const [count, setCount] = useState(0);
const intervalRef = useRef(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(intervalRef.current);
}, []);
const stop = () => {
clearInterval(intervalRef.current);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={stop}>Stop</button>
</div>
);
}
// Отслеживать предыдущее значение
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
function Component({ value }) {
const prevValue = usePrevious(value);
return (
<p>
Current: {value}, Previous: {prevValue}
</p>
);
}
Custom Hooks
Hook для загрузки данных
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function fetchData() {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const result = await response.json();
if (!cancelled) {
setData(result);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchData();
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
// Использование
function UserList() {
const { data: users, loading, error } = useFetch('/api/users');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Hook для Local Storage
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
// Инициализировать из localStorage или использовать начальное значение
const [value, setValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
// Синхронизировать с localStorage при изменении значения
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error(error);
}
}, [key, value]);
return [value, setValue];
}
// Использование
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);
return (
<div>
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<input
type="range"
min="12"
max="24"
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
/>
</div>
);
}
Hook для debounce
import { useState, useEffect } from 'react';
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Использование: поиск с debounce
function SearchComponent() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useDebounce(searchTerm, 500);
const { data: results } = useFetch(
debouncedSearch ? `/api/search?q=${debouncedSearch}` : null
);
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
<ul>
{results?.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
Hook для размера окна
import { useState, useEffect } from 'react';
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
// Использование
function ResponsiveComponent() {
const { width } = useWindowSize();
return (
<div>
{width < 768 ? <MobileView /> : <DesktopView />}
</div>
);
}
Правила Hooks
- Вызывайте только на верхнем уровне: никогда не вызывайте в циклах, условиях или вложенных функциях
- Вызывайте только в React-функциях: компонентах или custom hooks
- Называйте custom hooks с префиксом "use": это позволяет проверять правила линтером
// ПЛОХО
function Component({ condition }) {
if (condition) {
const [value, setValue] = useState(0); // Ошибка!
}
}
// ХОРОШО
function Component({ condition }) {
const [value, setValue] = useState(0);
if (condition) {
// Используйте значение здесь
}
}
Ключевые выводы
- useState для простого состояния: для объектов при обновлении нужен spread
- useEffect с очисткой: предотвращайте утечки памяти и race conditions
- useCallback/useMemo используйте осторожно: преждевременная оптимизация обходится дорого
- Custom hooks для переиспользования: выносите общие паттерны
- useReducer для сложного состояния: лучше, чем несколько useState
- Массивы зависимостей важны: пропущенные зависимости приводят к багам
Hooks — основа современного React: освоив их, вы будете писать более чистые и поддерживаемые компоненты.