JSX и компоненты
Функциональные компоненты
Компоненты React — это функции JavaScript, возвращающие JSX. Имена компонентов должны начинаться с заглавной буквы (строчные = HTML-теги). Props передаются как атрибуты и деструктурируются в параметре. JSX — синтаксический сахар для React.createElement(). Всегда возвращайте один корневой элемент (или используйте Fragments). Компоненты должны быть чистыми — одинаковые props = одинаковый вывод.
// Basic function component
function Welcome({ name }) {
return <h1>Hello, {name}!</h1>;
}
// Arrow function component
const Greeting = ({ name = 'Guest' }) => (
<p>Welcome, {name}!</p>
);
// Composing components
function App() {
return (
<div>
<Welcome name="Alice" />
<Welcome name="Bob" />
<Greeting />
</div>
);
}JSX-выражения
JSX позволяет встраивать JavaScript-выражения в фигурных скобках {}. Можно вставлять переменные, вызовы функций, тернарные операторы и любые выражения, возвращающие значение. Операторы (if, for, switch) не разрешены напрямую — используйте тернарный оператор или IIFE. Логические значения (true, false), null и undefined не отображаются ничего. Числа и строки отображаются как текст. Объекты не являются допустимыми дочерними элементами React.
function Expression({ user, items }) {
const fullName = user.first + ' ' + user.last;
const itemCount = items.length;
return (
<div>
{/* Expressions in curly braces */}
<h1>{fullName}</h1>
<p>{itemCount} items</p>
{/* Conditional */}
<p>{itemCount > 0 ? 'In stock' : 'Sold out'}</p>
{/* Method calls */}
<p>{fullName.toUpperCase()}</p>
{/* Numbers and booleans render as nothing */}
<p>{false}{null}{undefined}</p>
</div>
);
}Фрагменты и списки в JSX
Фрагменты (<>...</>) группируют несколько элементов без добавления лишних DOM-узлов — чище, чем оборачивание в <div>. Используйте <Fragment key={...}>, когда нужно передать key. Массивам элементов JSX требуются уникальные props key. Хотя индекс массива как key работает для статических списков, используйте стабильные ID для динамических списков для предотвращения багов рендеринга. Фрагменты улучшают производительность, уменьшая количество ненужных элементов-обёрток.
// Fragment: group without extra DOM node
function App() {
return (
<>
<header>Header</header>
<main>Content</main>
<footer>Footer</footer>
</>
);
}
// Array of elements (needs keys)
function List() {
const fruits = ['Apple', 'Banana', 'Cherry'];
return (
<ul>
{fruits.map((fruit, i) => (
<li key={i}>{fruit}</li>
))}
</ul>
);
}Условный рендеринг
React предлагает несколько паттернов условного рендеринга. Ранние возвраты для логики if/else. Тернарный оператор (cond ? A : B) для выбора одного из двух. Логическое И (cond && <Component/>) для показа/скрытия. IIFE для сложного ветвления. Избегайте встраивания сложной логики в JSX — извлекайте в переменные или вспомогательные функции. Для операторов switch используйте объект поиска или извлекайте в отдельную функцию. Ложные значения (0, '') рендерятся, поэтому используйте тернарный оператор вместо && для чисел.
function Greeting({ isLoggedIn, user }) {
// 1. If/else (use early return)
if (!isLoggedIn) return <Login />;
// 2. Ternary operator
return (
<div>
{user ? <Dashboard user={user} /> : <Loading />}
{/* 3. Logical AND (render if truthy) */}
{user.isAdmin && <AdminPanel />}
{/* 4. IIFE for complex logic */}
{(() => {
if (user.role === 'admin') return <Admin />;
if (user.role === 'mod') return <Mod />;
return <User />;
})()}
</div>
);
}Children и render props
Prop children содержит элементы между открывающим и закрывающим тегами — необходим для композируемых компонентов (карточки, модальные окна, макеты). Render props передают функцию как prop, которая получает данные и возвращает JSX — альтернатива HOC и хукам для разделения логики. Хотя render props менее распространены с хуками, они всё ещё полезны для паттернов внедрения компонентов. children — специальный prop, который не нужно передавать явно.
// children prop: content between tags
function Card({ title, children }) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</div>
);
}
// Usage
<Card title="Profile">
<p>Name: Alice</p>
<p>Age: 30</p>
</Card>
// Render prop pattern
function DataProvider({ render }) {
const data = fetchData();
return <div>{render(data)}</div>;
}Props
Передача props
Props — данные только для чтения, передаваемые от родителя потомку. Они могут быть любым значением JavaScript: строки, числа, булевы значения, массивы, объекты или функции. Строковые значения используют кавычки (name='Alice'), все остальные — фигурные скобки (age={30}). Функции как props обеспечивают связь потомок-родитель (колбэки). Props идут вниз — потомки не могут изменять props. Для двусторонней привязки данных поднимите состояние в общего родителя.
// Parent passes props to child
function App() {
return (
<User
name="Alice"
age={30}
isActive={true}
tags={['admin', 'dev']}
onClick={() => console.log('clicked')}
/>
);
}
// Child receives props
function User({ name, age, isActive, tags, onClick }) {
return (
<div onClick={onClick}>
<h1>{name}</h1>
<p>Age: {age}</p>
<p>Status: {isActive ? 'Active' : 'Inactive'}</p>
</div>
);
}Props по умолчанию и опциональные
Значения props по умолчанию задаются через деструктуризацию (param = defaultValue). Если prop не передан, он undefined. Используйте короткое замыкание (bio && <p>) или тернарный оператор для условного рендеринга опциональных props. PropTypes (устаревшее) или интерфейсы TypeScript могут проверять типы props во время разработки. defaultProps (классовые компоненты) устарели для функциональных компонентов — используйте значения по умолчанию через деструктуризацию.
// Default values via destructuring
function Button({ color = 'blue', size = 'md', children }) {
return (
<button className={'btn btn-' + color + ' btn-' + size}>
{children}
</button>
);
}
// Optional props (undefined if not passed)
function Profile({ name, bio }) {
return (
<div>
<h1>{name}</h1>
{bio && <p>{bio}</p>}
</div>
);
}
// Usage
<Button>Click</Button> {/* color='blue', size='md' */}
<Profile name="Alice" /> {/* bio is undefined */}Spread и rest props
Оператор spread (...props) передаёт все props дочернему элементу — полезно для компонентов-обёрток (HOC, styled components). Оператор rest собирает оставшиеся props после деструктуризации определённых. Этот паттерн распространён в дизайн-системах, где компонент-обёртка перенаправляет неизвестные props элементу DOM. Будьте осторожны: spread может переопределить явные атрибуты — порядок важен ({...props} className='x' vs className='x' {...props}).
// Spread: pass all props to child
function Input(props) {
return <input {...props} className="input" />;
}
// Usage
<Input type="text" placeholder="Name" value="Alice" />
// Rest: collect remaining props
function Button({ label, ...rest }) {
return <button {...rest}>{label}</button>;
}
// Selective spreading
function Card({ title, children, ...divProps }) {
return (
<div {...divProps}>
<h2>{title}</h2>
{children}
</div>
);
}Prop Types и TypeScript
Интерфейсы TypeScript обеспечивают проверку типов во время компиляции для props — рекомендуемый подход для новых React-проектов. Опциональные props используют ? (isActive?: boolean). PropTypes обеспечивают проверку во время выполнения (только в разработке) и полезны для JavaScript-проектов без TypeScript. isRequired гарантирует, что prop предоставлен. TypeScript ловит ошибки типов до выполнения, что делает его превосходным для больших кодовых баз.
// TypeScript interface (recommended)
interface UserProps {
name: string;
age: number;
isActive?: boolean; // optional
onClick: (id: number) => void;
}
function User({ name, age, isActive = true, onClick }: UserProps) {
return <div onClick={() => onClick(1)}>{name}, {age}</div>;
}
// PropTypes (runtime checking, legacy)
import PropTypes from 'prop-types';
User.propTypes = {
name: PropTypes.string.isRequired,
age: PropTypes.number,
isActive: PropTypes.bool,
};Prop drilling и Context
Prop drilling возникает, когда props проходят через не сколько слоёв компонентов, которые их не используют. Для 2-3 уровней это допустимо. Для более глубоких деревьев используйте Context API, библиотеки управления состоянием (Redux, Zustand) или композицию компонентов. Композиция (передача компонентов как props или children) часто решает drilling изящнее, чем Context. Спросите: нужен ли каждому промежуточному компоненту этот data? Если нет, пересмотрите структуру компонентов.
// Prop drilling: passing through multiple levels
function App() {
const [user, setUser] = useState(null);
return <Layout user={user} />;
}
function Layout({ user }) {
return <Sidebar user={user} />;
}
function Sidebar({ user }) {
return <UserInfo user={user} />;
}
// Solution: Context API (see State Management section)
// Avoid drilling more than 2-3 levelsuseState и состояние
Базовый useState
useState — фундаментальный хук для добавления состояния в функциональные компоненты. Возвращает массив: [currentValue, setterFunction]. Начальное значение может быть любого типа. Вызов setter вызывает повторный рендер с новым значением. Обновления состояния асинхронны — значение не изменяется сразу после вызова setCount. Каждый экземпляр компонента имеет своё независимое состояние. Setter стабилен (одна ссылка между рендерами).
import { useState } from 'react';
function Counter() {
// [currentValue, setterFunction] = useState(initialValue)
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}Функциональные обновления
Когда новое состояние зависит от предыдущего, используйте функциональное обновление: setCount(prev => prev + 1). Это гарантирует работу с последним состоянием, даже если несколько обновлений пакетируются. Без функциональных обновлений быстрые последовательные вызовы могут использовать устаревшее состояние. React 18 автоматически пакетирует обновления состояния (даже в промисах и таймаутах), поэтому функциональные обновления необходимы для корректности.
function Counter() {
const [count, setCount] = useState(0);
// BAD: may not work correctly with rapid updates
const incrementBad = () => setCount(count + 1);
// GOOD: functional update uses previous state
const incrementGood = () => setCount(prev => prev + 1);
// Batch updates
const addThree = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
};
return <button onClick={addThree}>Count: {count}</button>;
}Состояние с объектами и массивами
Никогда не мутируйте состояние напрямую — всегда создавайте новый объект/массив. Для объектов используйте оператор spread для копирования существующих свойств: {...prev, [field]: value}. Для массивов используйте spread для добавления ([...prev, newItem]), filter для удаления и map для обновления. React сравнивает ссылки для обнаружения изменений — мутированные объекты имеют ту же ссылку, поэтому React не перерендерит. Это источник №1 багов React для новичков.
function Form() {
const [form, setForm] = useState({ name: '', email: '', age: 0 });
// BAD: mutates state directly
// form.name = 'Alice'; setForm(form);
// GOOD: spread to create new object
const updateField = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }));
};
return (
<input
value={form.name}
onChange={e => updateField('name', e.target.value)}
/>
);
}
// Array state
function TodoList() {
const [todos, setTodos] = useState([]);
const addTodo = (text) => setTodos(prev => [...prev, { id: Date.now(), text }]);
const removeTodo = (id) => setTodos(prev => prev.filter(t => t.id !== id));
}Ленивое начальное состояние
Если начальное состояние требует дорогостоящих вычислений, передайте функцию в useState (ленивая инициализация). Функция запускается только при первом рендере, а не при каждом перерендере. Это важно для парсинга localStorage, извлечения из IndexedDB или любой интенсивной настройки. Форма функции: useState(() => initialValue). Для простых значений (числа, строки) просто передайте значение напрямую — ленивая инициализация не нужна.
import { useState } from 'react';
function ExpensiveInit() {
// BAD: runs on every render (even though result is ignored)
const [data, setData] = useState(computeExpensiveValue());
// GOOD: lazy initialization — function runs only once
const [data2, setData2] = useState(() => computeExpensiveValue());
// Reading from localStorage
const [user, setUser] = useState(() => {
const saved = localStorage.getItem('user');
return saved ? JSON.parse(saved) : null;
});
return <div>{data2}</div>;
}Несколько переменных состояния
Используйте несколько вызовов useState для независимых значений, а не один большой объект. Это упрощает обновления (не нужно spread) и предотвращает ненужные перерендеры. Группируйте связанные значения в одном объекте состояния (например, поля формы). Для сложной логики состояния с несколькими подзначениями рассмотрите useReducer. Правило: если обновления состояния независимы — используйте отдельные useState; если они связаны/взаимозависимы — useReducer или один объект.
function LoginForm() {
// Multiple independent state variables
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
setIsSubmitting(true);
// ... validation and submission
};
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" value={password}
onChange={e => setPassword(e.target.value)} />
<button disabled={isSubmitting}>Submit</button>
</form>
);
}useEffect и побочные эффекты
Базовый useEffect
useEffect выполняет побочные эффекты после рендера. Функция эффекта запускается после отрисовки компонента. Функция очистки (возвращаемая) запускается перед следующим эффектом и при размонтировании — необходима для очистки таймеров, подписок и слушателей. Массив зависимостей контролирует, когда эффект перезапускается: [] = один раз при монтировании, [dep] = при изменении dep, без массива = каждый рендер. Всегда выполняйте очистку для предотвращения утечек памяти.
import { useState, useEffect } from 'react';
function Timer() {
const [seconds, setSeconds] = useState(0);
// Runs after every render
useEffect(() => {
const interval = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
// Cleanup function runs before next effect or unmount
return () => clearInterval(interval);
}, []); // empty array = run once on mount
return <p>Seconds: {seconds}</p>;
}Массив зависимостей
Массив зависимостей критичен для поведения useEffect. Пустой массив [] = только при монтировании (как componentDidMount). С зависимостями [a, b] = при монтировании и когда a или b изменяются. Без массива = каждый рендер (редко то, что нужно). Отсутствующие зависимости вызывают устаревшие замыкания. Включение ненужных зависимостей вызывает чрезмерные перезапуски. Используйте правило ESLint exhaustive-deps для отлова ошибок. Каждое значение из области компонента, используемое в эффекте, должно быть в deps.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// Runs once on mount (empty deps)
useEffect(() => {
console.log('Component mounted');
}, []);
// Runs when userId changes
useEffect(() => {
fetch('/api/users/' + userId)
.then(r => r.json())
.then(setUser);
}, [userId]); // re-run when userId changes
// Runs on every render (no deps) - rarely needed
useEffect(() => {
console.log('Every render');
});
return <div>{user?.name}</div>;
}Очистка и подписки
Очистка необходима для подписок, слушателей событий, таймеров и соединений WebSocket. Без очистки возникают утечки памяти и дублирующиеся обработчики. Функция очистки запускается: (1) перед следующим перезапуском эффекта, (2) при размонтировании компонента. Для WebSocket/слушателей событий всегда удаляйте их в очистке. Для состояния, зависящего от эффекта, сбрасывайте его в очистке, чтобы избежать показа устаревших данных от предыдущего roomId.
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const ws = new WebSocket('wss://chat.example.com/' + roomId);
ws.onmessage = (event) => {
setMessages(prev => [...prev, JSON.parse(event.data)]);
};
// Cleanup: close connection when roomId changes or unmount
return () => {
ws.close();
setMessages([]); // reset for new room
};
}, [roomId]);
// Window event listener
useEffect(() => {
const handleResize = () => console.log(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <div>{messages.length} messages</div>;
}Получение данных
Получение данных в useEffect требует флага отмены для предотвращения установки состояния после размонтирования (вызывает предупреждения React). Флаг 'cancelled' гарантирует, что setUsers/setError/setLoading запускаются только если компонент всё ещё смонтирован. Для production-приложений рассмотрите использование библиотеки получения данных (React Query, SWR), которая обрабатывает кэширование, дедупликацию и состояния гонки автоматически. Пустой массив зависимостей [] гарантирует получение один раз при монтировании.
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
const fetchData = async () => {
try {
setLoading(true);
const res = await fetch('/api/users');
const data = await res.json();
if (!cancelled) setUsers(data);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
};
fetchData();
return () => { cancelled = true; };
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}useLayoutEffect vs useEffect
useEffect запускается асинхронно после отрисовки браузером — пользователи могут увидеть кратковременное мерцание, если вы измеряете DOM-элементы. useLayoutEffect запускается синхронно после мутаций DOM, но до отрисовки — предотвращает визуальное мерцание. Используйте useLayoutEffect для измерений DOM (getBoundingClientRect, позиция прокрутки), влияющих на макет. Используйте useEffect для всего остального (не блокирует отрисовку). На сервере useLayoutEffect предупреждает — используйте паттерн useIsomorphicLayoutEffect.
import { useState, useEffect, useLayoutEffect } from 'react';
function Tooltip({ text }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
// useEffect: runs AFTER paint (user may see flash)
useEffect(() => {
const rect = document.getElementById('tip').getBoundingClientRect();
setPosition({ x: rect.x, y: rect.y });
}, [text]);
// useLayoutEffect: runs BEFORE paint (no flash)
useLayoutEffect(() => {
const rect = document.getElementById('tip').getBoundingClientRect();
setPosition({ x: rect.x, y: rect.y });
}, [text]);
return <div id="tip" style={{ left: position.x, top: position.y }}>{text}</div>;
}useRef, useMemo и useCallback
Основы useRef
useRef возвращает изменяемый объект { current: value }, сохраняющийся между рендерами. В отличие от состояния, изменение ref.current НЕ вызывает перерендер. Частые использования: (1) доступ к DOM-элементам (через атрибут ref), (2) хранение изменяемых значений, не влияющих на рендеринг (таймеры, предыдущие значения), (3) хранение последнего значения для использования в колбэках. Объект ref имеет ту же идентичность между рендерами. Начальное значение передаётся в useRef(initialValue).
import { useRef } from 'react';
function FocusInput() {
// ref to access DOM element
const inputRef = useRef(null);
const focus = () => inputRef.current.focus();
const clear = () => {
inputRef.current.value = '';
inputRef.current.focus();
};
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focus}>Focus</button>
<button onClick={clear}>Clear</button>
</div>
);
}useRef для изменяемых значений
useRef хранит изменяемые значения, сохраняющиеся между рендерами, не вызывая перерендеров. Идеально для: ID таймеров, ссылок WebSocket, отслеживания предыдущего состояния и подсчёта рендеров. Поскольку изменение ref.current не вызывает перерендер, UI не обновляется при изменении — используйте состояние для значений, которые должны влиять на UI. Паттерн подсчёта рендеров (ref.current++) полезен для отладки, но не должен использоваться в production-логике.
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
const renderCount = useRef(0);
// Track render count (doesn't trigger re-render)
renderCount.current++;
const start = () => {
if (intervalRef.current) return;
intervalRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
return (
<div>
<p>{seconds}s (render #{renderCount.current})</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}useMemo
useMemo мемоизирует (кэширует) вычисленное значение, пересчитывая только при изменении зависимостей. Используйте для дорогостоящих вычислений (фильтрация, сортировка, сложная математика), чтобы избежать повторного выполнения при каждом рендере. Массив зависимостей работает как в useEffect. Чрезмерное использование может повредить производительности (у мемоизации свои накладные расходы) — мемоизируйте только действительно дорогие операции. useMemo также полезен для сохранения ссылок на объекты для предотвращения перерендеров потомков.
import { useState, useMemo } from 'react';
function ProductList({ products, filter }) {
const [search, setSearch] = useState('');
// Memoize expensive computation
const filtered = useMemo(() => {
console.log('Filtering...');
return products
.filter(p => p.category === filter)
.filter(p => p.name.includes(search));
}, [products, filter, search]); // recompute only when these change
// Memoize a value
const totalPrice = useMemo(() =>
filtered.reduce((sum, p) => sum + p.price, 0),
[filtered]);
return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} />
<p>Total: ${totalPrice}</p>
{filtered.map(p => <div key={p.id}>{p.name}</div>)}
</div>
);
}useCallback
useCallback мемоизирует функцию, возвращая ту же ссылку между рендерами, пока не изменятся зависимости. Это предотвращает ненужные перерендеры мемоизированных дочерних компонентов (обёрнутых в memo()). Без useCallback каждый рендер родителя создаёт новую ссылку на функцию, вызывая перерендер потомков memo(). Используйте useCallback при передаче колбэков оптимизированным дочерним компонентам. Как и useMemo, не злоупотребляйте — только для функций, передаваемых как props мемоизированным потомкам.
import { useState, useCallback, memo } from 'react';
// Memoized child component
const Button = memo(function Button({ onClick, label }) {
console.log('Button rendered');
return <button onClick={onClick}>{label}</button>;
});
function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// Without useCallback: new function every render
// const handleClick = () => setCount(c => c + 1);
// With useCallback: stable function reference
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []); // empty deps = stable forever
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<Button onClick={handleClick} label="Click" />
<p>Count: {count}</p>
</div>
);
}Перенаправление refs
forwardRef позволяет родительским компонентам передавать ref DOM-элементу дочернего компонента. useImperativeHandle настраивает, что раскрывает ref — вместо DOM-узла можно раскрыть определённые методы (focus, clear, getValue). Полезно для создания переиспользуемых input-компонентов с императивными API. React 19 упростил refs (теперь ref — обычный prop), но forwardRef всё ещё нужен для библиотек. Избегайте чрезмерного использования императивных обработчиков — предпочитайте декларативные props.
import { useRef, forwardRef, useImperativeHandle } from 'react';
// forwardRef: pass ref to child component
const FancyInput = forwardRef(function FancyInput(props, ref) {
return <input ref={ref} className="fancy" {...props} />;
});
// useImperativeHandle: expose specific methods
const CustomInput = forwardRef(function CustomInput(props, ref) {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
clear: () => { inputRef.current.value = ''; },
getValue: () => inputRef.current.value,
}));
return <input ref={inputRef} />;
});
// Usage
function App() {
const ref = useRef(null);
return (
<>
<CustomInput ref={ref} />
<button onClick={() => ref.current.focus()}>Focus</button>
<button onClick={() => ref.current.clear()}>Clear</button>
</>
);
}useReducer и Context
Основы useReducer
useReducer — альтернатива useState для сложной логики состояния. Редьюсер — чистая функция: (state, action) => newState. Actions описывают, что произошло; редьюсер решает, как обновить состояние. Этот паттерн делает переходы состояния предсказуемыми и тестируемыми. Dispatch стабилен (та же ссылка). Всегда возвращайте новый объект состояния (никогда не мутируйте). Случай по умолчанию должен выбрасывать ошибку для неизвестных действий. Используйте useReducer, когда у состояния несколько подзначений или следующее состояние зависит от сложной логики.
import { useReducer } from 'react';
// Reducer function: (state, action) => newState
function counterReducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
case 'set':
return { count: action.payload };
default:
throw new Error('Unknown action: ' + action.type);
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
<button onClick={() => dispatch({ type: 'set', payload: 10 })}>Set 10</button>
</div>
);
}Сложный редьюсер
Сложные редьюсеры управляют несколькими связанными частями состояния. Каждый тип действия обрабатывает определённый переход состояния. Всегда spread предыдущее состояние ({...state}) для сохранения несвязанных полей. Для вложенных обновлений (как переключение todo) используйте map для создания нового массива с обновлённым элементом. Редьюсеры должны быть чистыми — без побочных эффектов, без вызовов API. Извлеките редьюсер в отдельный файл для тестируемости. Рассмотрите библиотеки вроде Redux Toolkit для очень сложного состояния.
const initialState = {
todos: [],
filter: 'all',
loading: false,
};
function todoReducer(state, action) {
switch (action.type) {
case 'add':
return { ...state, todos: [...state.todos, action.todo] };
case 'toggle':
return {
...state,
todos: state.todos.map(t =>
t.id === action.id ? { ...t, done: !t.done } : t
),
};
case 'delete':
return { ...state, todos: state.todos.filter(t => t.id !== action.id) };
case 'set_filter':
return { ...state, filter: action.filter };
case 'set_loading':
return { ...state, loading: action.loading };
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState);
// dispatch({ type: 'add', todo: { id: 1, text: 'Learn React', done: false } })
}Context API
Context API разделяет состояние между деревом компонентов без prop drilling. Создайте через createContext(defaultValue). Оберните потребителей в Provider с prop value. Потребляйте через useContext(Context). Изменение значения Context вызывает перерендер всех потребителей. Для производительности разделяйте контексты (ThemeContext, UserContext), чтобы компоненты перерендеривали только при изменении их конкретного контекста. Значение по умолчанию используется, когда ни один Provider не оборачивает потребителя.
import { createContext, useContext, useState } from 'react';
// 1. Create context with default value
const ThemeContext = createContext('light');
const UserContext = createContext(null);
// 2. Provider component
function App() {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(null);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<UserContext.Provider value={{ user, setUser }}>
<Page />
</UserContext.Provider>
</ThemeContext.Provider>
);
}
// 3. Consume context
function Page() {
const { theme } = useContext(ThemeContext);
const { user } = useContext(UserContext);
return <div className={'page theme-' + theme}>Hello {user?.name}</div>;
}Context с редьюсером
Сочетание Context с useReducer создаёт лёгкую систему глобального управления состоянием (мини-Redux). Provider раскрывает и состояние, и dispatch. Пользовательский хук (useStore) обеспечивает обработку ошибок при использовании вне провайдера. Этот паттерн отлично подходит для приложений среднего размера. Для очень больших приложений с частыми обновлениями рассмотрите разделение контекстов или использование Redux/Zustand для избежания перерендера всех потребителей при каждом изменении состояния.
import { createContext, useContext, useReducer } from 'react';
// Combine Context + useReducer for global state
const StoreContext = createContext(null);
function StoreProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StoreContext.Provider value={{ state, dispatch }}>
{children}
</StoreContext.Provider>
);
}
// Custom hook for easy consumption
function useStore() {
const context = useContext(StoreContext);
if (!context) throw new Error('useStore must be used within StoreProvider');
return context;
}
// Usage
function Component() {
const { state, dispatch } = useStore();
return <button onClick={() => dispatch({ type: 'action' })}>Click</button>;
}Производительность useContext
При изменении значения context ВСЕ потребители перерендеривают — даже если используют лишь небольшую часть значения. Для оптимизации: (1) Разделяйте контексты, чтобы компоненты подписывались только на нужное. (2) Мемоизируйте значение context с useMemo для предотвращения перерендеров, когда значение фактически не изменилось. (3) Используйте селекторы (библиотека use-context-selector) для точечной подписки. Для высокочастотных обновлений (как позиция мыши) Context может вызывать проблемы с производительностью — рассмотрите refs или внешние хранилища.
// SPLIT contexts for performance
const ThemeContext = createContext();
const UserContext = createContext();
const CartContext = createContext();
// Each provider manages its own state
function App() {
return (
<ThemeProvider>
<UserProvider>
<CartProvider>
<App />
</CartProvider>
</UserProvider>
</ThemeProvider>
);
}
// Component only re-renders when its context changes
function ThemedButton() {
const { theme } = useContext(ThemeContext);
// Won't re-render when user or cart changes
return <button className={theme}>Button</button>;
}
// Memoize context value to prevent unnecessary re-renders
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(() => ({ user, setUser }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}События и формы
Обработка событий
События React используют camelCase (onClick, а не onclick). Объект события — SyntheticEvent (обёртка над нативным событием). e.preventDefault() останавливает поведение по умолчанию (отправка формы, навигация по ссылке). e.stopPropagation() предотвращает всплытие события. Для передачи параметров в обработчики используйте стрелочные функции: onClick={() => handleDelete(id)}. Избегайте определения сложных обработчиков inline — извлекайте для читаемости. События React пулируются (до 17-й версии), поэтому вызывайте e.persist() при необходимости асинхронного доступа.
function App() {
// Click event
const handleClick = (e) => {
e.preventDefault();
console.log('Button clicked', e.target);
};
// With parameters (use arrow function)
const handleDelete = (id) => {
console.log('Delete item', id);
};
return (
<div>
<button onClick={handleClick}>Click</button>
<button onClick={() => handleDelete(42)}>Delete</button>
<div onMouseEnter={() => console.log('hover')}
onMouseLeave={() => console.log('leave')}>
Hover me
</div>
</div>
);
}Контролируемые input'ы
Контролируемые input'ы имеют значение, управляемое состоянием React. Prop value устанавливает значение input, а onChange обновляет состояние. Это делает React «единственным источником истины» для данных формы. Каждое нажатие клавиши вызывает обновление состояния и перерендер. Для сложных форм это может быть многословно — рассмотрите библиотеки вроде React Hook Form или Formik. Контролируемые input'ы обеспечивают валидацию в реальном времени и динамическое поведение. Всегда используйте onChange с value (или readOnly), чтобы избежать предупреждений React.
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
console.log('Email:', email, 'Password:', password);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit">Login</button>
</form>
);
}Форма с несколькими полями
Для форм со многими полями используйте один объект состояния и общую функцию handleChange. Атрибут name каждого input соответствует ключу состояния. Обработчик использует вычисляемые имена свойств ([name]: value) для обновления нужного поля. Для чекбоксов используйте checked вместо value. Этот паттерн значительно сокращает шаблонность. Для input-файлов используйте неконтролируемые input'ы (их нельзя полностью контролировать). Рассмотрите React Hook Form для сложных форм с валидацией.
function RegistrationForm() {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
country: 'us',
agree: false,
});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
const handleSubmit = (e) => {
e.preventDefault();
console.log(formData);
};
return (
<form onSubmit={handleSubmit}>
<input name="username" value={formData.username} onChange={handleChange} />
<input name="email" type="email" value={formData.email} onChange={handleChange} />
<select name="country" value={formData.country} onChange={handleChange}>
<option value="us">USA</option>
<option value="uk">UK</option>
</select>
<label>
<input type="checkbox" name="agree" checked={formData.agree} onChange={handleChange} />
Agree to terms
</label>
<button type="submit">Register</button>
</form>
);
}Неконтролируемые input'ы
Неконтролируемые input'ы используют refs для прямого доступа к значению DOM без состояния React. Prop defaultValue устанавливает начальное значение (не value). Это проще для форм, не требующих валидации в реальном времени или динамического поведения. Input-файлы должны быть неконтролируемыми (их значение только для чтения из соображений безопасности). Неконтролируемые input'ы также полезны для интеграции с не-React кодом. Компромисс: нельзя легко валидировать или преобразовывать ввод в реальном времени. Предпочитайте контролируемые input'ы для большинства случаев.
import { useRef } from 'react';
function UncontrolledForm() {
const emailRef = useRef(null);
const passwordRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
console.log('Email:', emailRef.current.value);
console.log('Password:', passwordRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} type="email" defaultValue="" />
<input ref={passwordRef} type="password" defaultValue="" />
<button type="submit">Submit</button>
</form>
);
}
// File input (must be uncontrolled)
function FileUpload() {
const fileRef = useRef(null);
return <input ref={fileRef} type="file" />;
}Валидация и обработка ошибок
Валидацию формы можно выполнять при отправке или при каждом изменении. Функция validate возвращает объект errors — пустой означает валидность. Отображайте ошибки условно рядом с каждым полем. Для лучшего UX валидируйте при blur (после ухода пользователя с поля), а не при каждом нажатии клавиши. Библиотеки вроде React Hook Form + Zod или Formik + Yup предоставляют надёжные схемы валидации, управление ошибками и отслеживание touch/blur. Всегда валидируйте и на сервере — клиентская валидация для UX, а не для безопасности.
function ValidatedForm() {
const [values, setValues] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const validate = () => {
const errs = {};
if (!values.email) errs.email = 'Email is required';
else if (!/\S+@\S+\.\S+/.test(values.email)) errs.email = 'Invalid email';
if (!values.password) errs.password = 'Password is required';
else if (values.password.length < 8) errs.password = 'Min 8 characters';
return errs;
};
const handleSubmit = (e) => {
e.preventDefault();
const errs = validate();
setErrors(errs);
if (Object.keys(errs).length === 0) {
console.log('Form valid', values);
}
};
return (
<form onSubmit={handleSubmit}>
<input value={values.email}
onChange={e => setValues(v => ({ ...v, email: e.target.value }))} />
{errors.email && <span className="error">{errors.email}</span>}
<input type="password" value={values.password}
onChange={e => setValues(v => ({ ...v, password: e.target.value }))} />
{errors.password && <span className="error">{errors.password}</span>}
<button type="submit">Submit</button>
</form>
);
}Списки и условный рендеринг
Рендеринг списков
Используйте .map() для преобразования массивов в элементы JSX. Каждому элементу нужен уникальный prop key — используйте стабильные ID (todo.id), а не индексы массива. Keys помогают React определить, какие элементы изменились (добавлены, удалены, переупорядочены) для эффективного обновления DOM. Использование индекса как key вызывает баги при переупорядочивании или вставке элементов списка в начало. Для пустых списков рендерите запасное сообщение. Рассмотрите useMemo для отфильтрованных/отсортированных списков для избежания перевычисления при каждом рендере.
function TodoList({ todos }) {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span>{todo.text}</span>
<button onClick={() => toggle(todo.id)}>
{todo.done ? 'Undo' : 'Done'}
</button>
</li>
))}
</ul>
);
}
// Filtering and sorting
function FilteredList({ items, filter }) {
const visible = items
.filter(item => item.category === filter)
.sort((a, b) => a.name.localeCompare(b.name));
return (
<ul>
{visible.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}Объяснение keys
Keys должны быть уникальными среди сиблингов (один родитель), но могут повторяться в разных списках. Keys помогают алгоритму согласования React: при изменении key React уничтожает и пересоздаёт компонент (теряя состояние). С keys-индексами вставка элемента в начало сдвигает все индексы, заставляя React перерендерить всё. Со стабильными keys-ID React рендерит только новый элемент. Keys не обязаны быть глобально уникальными — только уникальными в пределах списка. Не используйте случайные keys (Math.random()) — они меняются при каждом рендере.
// GOOD: stable, unique keys
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
// BAD: index as key (causes bugs with reordering)
{todos.map((todo, index) => (
<TodoItem key={index} todo={todo} />
))}
// When index keys are OK:
// - Static list (never reordered/filtered)
// - List items have no state
// - List is never prepended to
// Key must be unique among siblings
function List() {
return (
<div>
{users.map(u => <User key={u.id} user={u} />)}
{posts.map(p => <Post key={p.id} post={p} />)}
{/* IDs can repeat across different lists */}
</div>
);
}Паттерны условного рендеринга
Существует несколько паттернов условного рендеринга. Ранние возвраты — самый чистый способ для охранных условий (loading, error, auth). Переменные элементов работают для if/else в середине компонента. Тернарный (cond ? A : B) для выбора в JSX. Логическое И (cond && <X/>) для показа/скрытия. Объект поиска для поведения наподобие switch. Избегайте вложенных тернарных операторов — извлекайте в переменные или компоненты. Для чисел используйте тернарный вместо && (0 && <X/> рендерит 0).
function UserDashboard({ user, loading, error }) {
// 1. Early returns for loading/error states
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return <Login />;
// 2. Element variables
let greeting;
if (user.isAdmin) {
greeting = <h1>Welcome Admin {user.name}</h1>;
} else {
greeting = <h1>Welcome {user.name}</h1>;
}
return (
<div>
{greeting}
{/* 3. Ternary for either/or */}
{user.hasNotifications ? <NotificationBadge /> : null}
{/* 4. && for show/hide */}
{user.isAdmin && <AdminPanel />}
{/* 5. Switch via object lookup */}
{{ free: <FreePlan />, pro: <ProPlan />, enterprise: <EnterprisePlan /> }
[user.plan]}
</div>
);
}Фильтрация и поиск списков
Поиск/фильтрация списков сочетает useState для фильтров с useMemo для производительности. Функция filter проверяет и запрос, и категорию. Всегда обрабатывайте пустое состояние (нет результатов). Для больших списков (1000+ элементов) рассмотрите виртуализацию (react-window, react-virtualized) для рендеринга только видимых элементов. Debounce поиска для API-вызовов. Регистронезависимый поиск использует toLowerCase(). Для сложной фильтрации извлеките в отдельную функцию или пользовательский хук.
function SearchableList({ items }) {
const [query, setQuery] = useState('');
const [category, setCategory] = useState('all');
// Memoize filtered results
const filtered = useMemo(() => {
return items.filter(item => {
const matchesQuery = item.name.toLowerCase().includes(query.toLowerCase());
const matchesCategory = category === 'all' || item.category === category;
return matchesQuery && matchesCategory;
});
}, [items, query, category]);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
<select value={category} onChange={e => setCategory(e.target.value)}>
<option value="all">All</option>
<option value="food">Food</option>
<option value="tech">Tech</option>
</select>
{filtered.length === 0 ? (
<p>No results found</p>
) : (
<ul>
{filtered.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
)}
</div>
);
}Динамические компоненты
Динамический рендеринг компонентов использует объект поиска для сопоставления типов с компонентами. Это распространено в CMS-управляемом контенте, конструкторах форм и конструкторах страниц. Карта компонентов избегает длинных цепочек switch/if. Всегда обрабатывайте неизвестные типы компонентом-фолбэком. Spread props ({...block.props}) для передачи всех свойств динамическому компоненту. Этот паттерн гибок и расширяем — добавление нового типа блока требует лишь добавления в карту. Капитализируйте переменную (Component), чтобы JSX трактовал её как компонент.
// Render different components based on type
const componentMap = {
text: TextBlock,
image: ImageBlock,
video: VideoBlock,
quote: QuoteBlock,
};
function ContentRenderer({ blocks }) {
return (
<div>
{blocks.map(block => {
const Component = componentMap[block.type];
if (!Component) return <UnknownBlock key={block.id} type={block.type} />;
return <Component key={block.id} {...block.props} />;
})}
</div>
);
}
// Usage
const blocks = [
{ id: 1, type: 'text', props: { content: 'Hello' } },
{ id: 2, type: 'image', props: { src: 'pic.jpg', alt: 'Picture' } },
];Оптимизация производительности
React.memo
React.memo оборачивает компонент для предотвращения перерендеров, когда props не изменились (поверхностное сравнение). Используйте для компонентов, часто рендерящихся с теми же props. Второй аргумент — пользовательская функция сравнения: верните true для пропуска перерендера, false для перерендера. memo помогает только если компонент дорог в рендере или является потомком часто рендерящего родителя. Не оборачивайте каждый компонент — у мемоизации есть накладные расходы. Комбинируйте с useCallback/useMemo для максимального эффекта.
import { memo } from 'react';
// Memoized component: only re-renders if props change
const ExpensiveCard = memo(function ExpensiveCard({ title, content }) {
console.log('Card rendered');
return (
<div className="card">
<h2>{title}</h2>
<p>{content}</p>
</div>
);
});
// Custom comparison function
const MyComponent = memo(function MyComponent(props) {
return <div>{props.value}</div>;
}, (prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
return prevProps.value === nextProps.value;
});Разделение кода
Разделение кода уменьшает начальный размер bundle, загружая компоненты по требованию. React.lazy + Suspense включает динамические импорты. Prop fallback показывается во время загрузки компонента. Разделение по маршрутам (ленивая загрузка компонентов страниц) — наиболее эффективно. Разделение по компонентам полезно для тяжёлых компонентов (графики, редакторы), не нужных сразу. Каждый ленивый импорт создаёт отдельный chunk. Используйте React.lazy для default-экспортов; для именованных экспортов оберните в модуль.
import { lazy, Suspense } from 'react';
// Lazy load component (code-split)
const AdminPanel = lazy(() => import('./AdminPanel'));
const Dashboard = lazy(() => import('./Dashboard'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Router>
<Route path="/admin" element={<AdminPanel />} />
<Route path="/dashboard" element={<Dashboard />} />
</Router>
</Suspense>
);
}
// Route-based splitting (most common)
// Component-based splitting
const HeavyChart = lazy(() => import('./HeavyChart'));
function Page({ showChart }) {
return (
<div>
{showChart && (
<Suspense fallback={<Spinner />}>
<HeavyChart />
</Suspense>
)}
</div>
);
}Виртуализация для длинных списков
Виртуализация рендерит только видимые элементы длинного списка, значительно повышая производительность. react-window и react-virtualized — популярные библиотеки. Вместо рендеринга 10 000 DOM-узлов рендерится только ~12 (видимые) с прокручиваемым контейнером. Это уменьшает размер DOM и время рендера. Используйте виртуализацию для списков со 100+ элементами. Компромисс: более сложная реализация, возможные проблемы с поиском/нахождением (элементы не в DOM). Списки переменной высоты требуют VariableSizeList.
import { FixedSizeList } from 'react-window';
// Virtualized list: only renders visible items
function BigList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>
{items[index].name}
</div>
);
return (
<FixedSizeList
height={600}
width="100%"
itemCount={items.length}
itemSize={50}
>
{Row}
</FixedSizeList>
);
}
// Without virtualization: rendering 10,000 items is slow
// With virtualization: only ~12 visible items are renderedDebouncing и throttling
Debouncing откладывает выполнение до паузы в активности (пользователь перестал печатать). Throttling ограничивает выполнение одним разом за интервал. Оба предотвращают чрезмерные API-вызовы или вычисления. Хук useDebounce обно вляет debounced-значение только после того, как пользователь перестал печатать на указанную задержку. Это необходимо для полей поиска, обработчиков resize и событий прокрутки. Для throttling используйте библиотеку вроде lodash.throttle или реализуйте с метками времени. Всегда очищайте таймеры в useEffect.
import { useState, useEffect } from 'react';
// Debounce hook: delays execution until user stops typing
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
// API call only fires when user stops typing for 300ms
useEffect(() => {
if (debouncedQuery) {
fetch('/api/search?q=' + debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}Профилирование и оптимизация
Компонент Profiler измеряет времена рендера. phase — 'mount' или 'update'. actualDuration — время рендера в миллисекундах. Используйте React DevTools Profiler для визуальных флэйм-чартов. Перед оптимизацией профилируйте для нахождения реальных узких мест — не угадывайте. Частые проблемы производительности: (1) ненужные перерендеры (исправьте memo/useMemo/useCallback), (2) дорогие вычисления (useMemo), (3) большие списки (виртуализация), (4) большие bundles (разделение кода). Преждевременная оптимизация тратит время — сначала измерьте.
import { Profiler } from 'react';
function App() {
const onRender = (id, phase, actualDuration) => {
console.log(id + ' ' + phase + ': ' + actualDuration + 'ms');
};
return (
<Profiler id="App" onRender={onRender}>
<ExpensiveComponent />
</Profiler>
);
}
/* Optimization checklist:
1. React.memo for expensive components
2. useMemo for expensive calculations
3. useCallback for props passed to memoized children
4. Code splitting (lazy) for routes
5. Virtualization for long lists
6. Debounce rapid events (search, resize)
7. Avoid inline objects/functions as props
8. Use keys correctly in lists
9. Profile with React DevTools Profiler
10. Check unnecessary re-renders with why-did-you-render
*/Паттерны и границы ошибок
Пользовательские хуки
Пользовательские хуки извлекают переиспользуемую состояние-содержащую логику в функцию с префиксом 'use'. Они могут вызывать другие хуки. Пользовательские хуки — основной способ разделения логики между компонентами (заменяя HOC и render props). Возвращайте объект для нескольких значений или значение/массив для одного. Всегда обрабатывайте состояния loading и error. Паттерн отмены (флаг cancelled) предотвращает обновления состояния после размонтирования. Называйте хуки с префиксом 'use' для работы правил ESLint.
import { useState, useEffect } from 'react';
// Reusable data fetching hook
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(url)
.then(res => {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(data => { if (!cancelled) { setData(data); setError(null); }})
.catch(err => { if (!cancelled) setError(err.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// Usage
function UserProfile({ id }) {
const { data: user, loading, error } = useFetch('/api/users/' + id);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <h1>{user.name}</h1>;
}Хук useLocalStorage
useLocalStorage сохраняет состояние в localStorage. Ленивый инициализатор читает из localStorage при монтировании. useEffect записывает в localStorage при каждом изменении значения. try/catch обрабатывает ошибки превышения квоты и ошибки парсинга JSON (повреждённые данные). Этот паттерн работает для любого постоянного состояния: темы, пользовательские настройки, черновой контент. Для SSR-совместимости проверяйте typeof window !== 'undefined'. Для синхронизации между вкладками слушайте событие 'storage'. Похожие хуки: useSessionStorage, useCookie.
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key);
return saved ? JSON.parse(saved) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error('LocalStorage error:', e);
}
}, [key, value]);
return [value, setValue];
}
// Usage
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Current: {theme}
</button>
);
}Границы ошибок
Границы ошибок перехватывают ошибки в методах рендера/жизненного цикла дочерних компонентов, предотвращая падение всего приложения. Они должны быть классовыми компонентами (хукового эквивалента пока нет). getDerivedStateFromError обновляет состояние для показа запасного UI. componentDidCatch логирует ошибки (отправляйте в Sentry, LogRocket и т. д.). Границы ошибок НЕ перехватывают: обработчики событий, асинхронный код, setTimeout, ошибки в самой границе. Оборачивайте конкретные секции для изоляции сбоев. Кнопка «Попробовать снова» сбрасывает состояние ошибки.
import { Component } from 'react';
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('Caught error:', error, errorInfo);
// Send to error reporting service
// logErrorToService(error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div>
<h1>Something went wrong.</h1>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
)
);
}
return this.props.children;
}
}
// Usage: wrap components
<ErrorBoundary fallback={<ErrorPage />}>
<App />
</ErrorBoundary>Компоненты высшего порядка (HOC)
Компоненты высшего порядка (HOC) — функции, принимающие компонент и возвращающие улучшенный. Они были основным паттерном для разделения логики до хуков. Частые использования: аутентификация, состояния загрузки, темизация. HOC могут вызывать «ад обёрток» (глубоко вложенные компоненты) и коллизии props. Для нового кода предпочитайте пользовательские хуки — они проще, композируемее и не добавляют в дерево компонентов. HOC всё ещё полезны для классовых компонентов или при интеграции с библиотеками, требующими их.
// HOC: function that takes a component and returns a new one
function withLoading(Component) {
return function WithLoading({ isLoading, ...props }) {
if (isLoading) return <div>Loading...</div>;
return <Component {...props} />;
};
}
// HOC for authentication
function withAuth(Component) {
return function WithAuth(props) {
const { user } = useContext(AuthContext);
if (!user) return <Redirect to="/login" />;
return <Component {...props} user={user} />;
};
}
// Usage
const UserList = withLoading(withAuth(BaseUserList));
// Note: Prefer hooks over HOCs for new code
// HOCs are mainly for class components or library compatibilityСоставные компоненты
Составные компоненты позволяют пользователям компоновать сложный компонент из простых частей. Родитель (Select) предоставляет context, а дочерние компоненты (Trigger, Options, Option) потребляют его. Этот паттерн используется библиотеками вроде Radix UI, Headless UI и React Aria. Преимущества: гибкий API (пользователи могут переупорядочивать/опускать части), неявное разделение состояния через context, чистый JSX. Компоненты прикрепляются как статические свойства (Select.Trigger). Это продвинутый паттерн — используйте для переиспользуемых UI-библиотек, а не одноразовых компонентов.
// Compound components: components that work together
function Select({ children, value, onChange }) {
const [isOpen, setIsOpen] = useState(false);
const context = { value, onChange, isOpen, setIsOpen };
return (
<SelectContext.Provider value={context}>
<div className="select">{children}</div>
</SelectContext.Provider>
);
}
Select.Trigger = function Trigger({ children }) {
const { isOpen, setIsOpen } = useContext(SelectContext);
return <button onClick={() => setIsOpen(!isOpen)}>{children}</button>;
};
Select.Options = function Options({ children }) {
const { isOpen } = useContext(SelectContext);
return isOpen ? <div className="options">{children}</div> : null;
};
Select.Option = function Option({ value, children }) {
const { onChange, setIsOpen } = useContext(SelectContext);
return (
<div onClick={() => { onChange(value); setIsOpen(false); }}>
{children}
</div>
);
}
// Usage: clean, declarative API
<Select value={val} onChange={setVal}>
<Select.Trigger>Choose...</Select.Trigger>
<Select.Options>
<Select.Option value="a">Option A</Select.Option>
<Select.Option value="b">Option B</Select.Option>
</Select.Options>
</Select>Пользовательские хуки
Хук useFetch
Пользовательские хуки извлекают переиспользуемую состояние-содержащую логику в функцию с префиксом 'use'. useFetch инкапсулирует получение данных с состояниями loading/error. AbortController отменяет запросы в полёте при размонтировании компонента или изменении URL (предотвращая состояния гонки и утечки памяти). Всегда включайте очистку в useEffect для асинхронных операций. Пользовательские хуки могут вызывать другие хуки (useState, useEffect, useContext). Они — основной способ разделения логики между компонентами без render props или HOC. Называйте их с префиксом 'use', чтобы работал линтер rules-of-hooks React.
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let abortController = new AbortController();
setLoading(true);
fetch(url, { ...options, signal: abortController.signal })
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.json();
})
.then((data) => { setData(data); setError(null); })
.catch((err) => {
if (err.name !== "AbortError") setError(err.message);
})
.finally(() => setLoading(false));
return () => abortController.abort();
}, [url]);
return { data, loading, error };
}
// Usage
function Profile({ userId }) {
const { data, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <div>{data.name}</div>;
}Хук useLocalStorage
useLocalStorage синхронизирует состояние React с localStorage. Ленивый инициализатор читает из localStorage только при первом рендере. useEffect записывает в localStorage при каждом изменении значения. try/catch обрабатывает случаи, когда localStorage заполнен или отключён (приватный режим). Этот хук делает постоянное состояние таким же простым, как useState. Для синхронизации между вкладками добавьте слушатель события storage. Для безопасности SSR защищайте доступ к window. Этот паттерн работает и для sessionStorage — просто замените API.
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = window.localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error("LocalStorage write failed:", e);
}
}, [key, value]);
return [value, setValue];
}
// Usage: persists state across page reloads
function Settings() {
const [theme, setTheme] = useLocalStorage("theme", "light");
const [fontSize, setFontSize] = useLocalStorage("fontSize", 14);
return (
<div>
<button onClick={() => setTheme("dark")}>Dark</button>
<button onClick={() => setTheme("light")}>Light</button>
</div>
);
}Хук useDebounce
useDebounce откладывает обновление значения, пока пользователь не перестанет печатать на указанную задержку. Это необходимо для полей поиска, автосохранения и API-вызовов, инициируемых пользовательским вводом — предотвращает чрезмерные вызовы при каждом нажатии клавиши. Функция очистки очищает тайм-аут, если значение изменится снова до истечения задержки. Debounced-значение обновляется только после паузы, инициируя нижестоящие эффекты (как API-вызовы) реже. Для немедленного выполнения с завершающим вызовом используйте useThrottle. Комбинируйте с useFetch для эффективного поиска по мере ввода.
function useDebounce(value, delay = 500) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Usage: debounce search input
function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
fetch(`/api/search?q=${debouncedQuery}`)
.then((res) => res.json())
.then(setResults);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}Хук usePrevious
usePrevious использует тот факт, что useEffect запускается после рендера — ref.current всё ещё хранит старое значение во время рендера, затем обновляется до нового значения после. Это распространённый паттерн для сравнения текущего и предыдущего состояния. useWindowSize отслеживает размеры viewport слушателем resize. Всегда очищайте слушатели событий в возврате useEffect для предотвращения утечек памяти. Эти утилитарные хуки демонстрируют, как пользовательские хуки инкапсулируют DOM-связанную логику, делая компоненты чище, а логику — переиспользуемой и тестируемой.
function usePrevious(value) {
const ref = useRef(null);
useEffect(() => {
ref.current = value; // update AFTER render
}, [value]);
return ref.current; // returns previous value during render
}
// Usage: compare current vs previous
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>Now: {count}, before: {prevCount}</p>
{count > prevCount && <p>Increased!</p>}
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
// useWindowSize hook
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handler = () =>
setSize({ width: innerWidth, height: innerHeight });
window.addEventListener("resize", handler);
return () => removeEventListener("resize", handler);
}, []);
return size;
}useToggle и useClipboard
useToggle упрощает булево состояние с функцией toggle, обёрнутой в useCallback для стабильной идентичности. useClipboard оборачивает API clipboard с состоянием обратной связи 'copied', автоматически сбрасываемым через тайм-аут. Эти небольшие утилитарные хуки сокращают шаблонность и стандартизируют частые паттерны в приложении. useCallback в обоих хуках предотвращает ненужные перерендеры мемоизированных потомков. Создание библиотеки небольших, сфокусированных хуков (useToggle, useClipboard, useMediaQuery, useOnClickOutside) ускоряет разработку и обеспечивает согласованное поведение.
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((p) => !p), []);
return [on, toggle, setOn];
}
function useClipboard(timeout = 2000) {
const [copied, setCopied] = useState(false);
const copy = useCallback((text) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), timeout);
});
}, [timeout]);
return [copied, copy];
}
// Usage
function CopyButton({ text }) {
const [copied, copy] = useClipboard();
return (
<button onClick={() => copy(text)}>
{copied ? "Copied!" : "Copy"}
</button>
);
}
function Modal({ children }) {
const [isOpen, toggle] = useToggle(false);
return (
<>
<button onClick={toggle}>Open</button>
{isOpen && <div className="modal">{children}</div>}
</>
);
}Portals
Создание portal
createPortal рендерит children в DOM-узел вне иерархии текущего компонента (обычно document.body). Это необходимо для модальных окон, тултипов и dropdown'ов, которые должны избежать родительских CSS-ограничений (overflow: hidden, контексты наложения z-index, transform, создающие новые контексты). Несмотря на рендеринг в другом месте DOM, всплытие событий React у portal работает так, как если бы он был в исходном дереве — onClick-обработчики предков всё ещё срабатывают. Это даёт лучшее из двух миров: визуальный выход из родительских ограничений, но логический поток событий сохранён.
import { createPortal } from "react-dom";
function Modal({ children, onClose }) {
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose}>✕</button>
{children}
</div>
</div>,
document.body // render target outside the DOM hierarchy
);
}
// Usage: the modal renders at body level,
// escaping any overflow:hidden or z-index stacking contexts
function App() {
const [show, setShow] = useState(false);
return (
<div style={{ overflow: "hidden", position: "relative" }}>
<button onClick={() => setShow(true)}>Open Modal</button>
{show && <Modal onClose={() => setShow(false)}>Hello!</Modal>}
</div>
);
}Модальное окно с portal и ловушкой фокуса
Production-модалке нужно больше, чем просто portal: управление фокусом (ловушка фокуса внутри, восстановление при закрытии), обработка клавиши Escape, блокировка прокрутки body, закрытие по клику вне. Эта реализация сохраняет ранее сфокусированный элемент, фокусирует модалку при открытии и восстанавливает фокус при закрытии — необходимо для пользователей скринридеров. Body overflow hidden предотвращает фоновую прокрутку. Функция очистки восстанавливает всё. Для полной ловушки фокуса (циклирование tab внутри модалки) используйте библиотеку вроде focus-trap-react. Всегда возвращайте null при закрытии для удаления из DOM.
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
const modal = modalRef.current;
const previouslyFocused = document.activeElement;
modal.focus();
const handleKey = (e) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
// Prevent body scroll
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", handleKey);
document.body.style.overflow = "";
previouslyFocused.focus(); // restore focus
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="overlay" onClick={onClose}>
<div ref={modalRef} tabIndex={-1} className="modal">
{children}
</div>
</div>,
document.body
);
}Тултипы с portals
Тултипы выигрывают от portals, так как должны переполнять родительские контейнеры и избегать обрезки. Позиция тултипа вычисляется из getBoundingClientRect() триггера и рендерится с position: fixed на уровне body. Это избегает проблем z-index и overflow. Для динамического позиционирования (переворот у края экрана) используйте библиотеку вроде Floating UI (ранее Popper.js). Portal гарантирует, что тултип никогда не обрезается предками с overflow: hidden. Координаты fixed-позиционирования относительны viewport, что упрощает вычисления.
function Tooltip({ children, text }) {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ x: 0, y: 0 });
const targetRef = useRef(null);
const show = () => {
const rect = targetRef.current.getBoundingClientRect();
setCoords({ x: rect.left, y: rect.top - 40 });
setVisible(true);
};
return (
<>
<span
ref={targetRef}
onMouseEnter={show}
onMouseLeave={() => setVisible(false)}
>
{children}
</span>
{visible && createPortal(
<div style={{ position: "fixed", left: coords.x, top: coords.y }}
className="tooltip">
{text}
</div>,
document.body
)}
</>
);
}
// Usage
<Tooltip text="Click to save">💾</Tooltip>Выпадающие меню с portals
Выпадающие меню сталкиваются с теми же проблемами overflow/z-index, что и тултипы. Portals решают визуальную проблему. Обработчик click-outside проверяет, находится ли цель клика вне ref триггера. Слушатель прокрутки в фазе перехвата (true третьим аргументом) закрывает меню при любой прокрутке, предотвращая отрыв меню от триггера. Для production используйте Floating UI, который обрабатывает обнаружение краёв, переворот, сдвиг и автоматическое обновление позиционирования при прокрутке/resize. Portals + правильная логика позиционирования = надёжные dropdown'ы, работающие в любом контексте вёрстки.
function Dropdown({ trigger, children }) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0 });
const ref = useRef(null);
const handleOpen = () => {
const rect = ref.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left });
setOpen(true);
};
useEffect(() => {
if (!open) return;
const handleClick = (e) => {
if (!ref.current?.contains(e.target)) setOpen(false);
};
const handleScroll = () => setOpen(false); // close on scroll
document.addEventListener("mousedown", handleClick);
window.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleClick);
window.removeEventListener("scroll", handleScroll, true);
};
}, [open]);
return (
<>
<div ref={ref} onClick={handleOpen}>{trigger}</div>
{open && createPortal(
<div style={{ position: "fixed", ...pos }} className="dropdown">
{children}
</div>,
document.body
)}
</>
);
}Всплытие событий portal
Ключевая особенность React Portals: всплытие событий следует дереву компонентов React, а не дереву DOM. onClick родительского компонента срабатывает, даже когда дочерний элемент перенесён в document.body через portal. Это значит, что context, состояние и делегирование событий работают естественно. Однако CSS-наследование НЕ пересекает границу portal — стили родителя не каскадируются на portal-контент, так как они в разных поддеревьях DOM. Нужно явно применять CSS (через классы или CSS-переменные на :root) для стилизации portal-контента. Это разделение обычно желательно для модалок/тултипов.
function PortalExample() {
// Despite rendering in document.body, events bubble
// through the React tree, not the DOM tree
return (
<div onClick={() => console.log("Parent clicked!")}>
<p>Click the button — parent handler fires!</p>
{createPortal(
<button onClick={() => console.log("Button clicked!")}>
I'm in a portal
</button>,
document.body
)}
</div>
);
}
// Clicking logs: "Button clicked!" then "Parent clicked!"
// This means context, state, and event delegation
// all work as if the portal were inline
// But CSS inheritance does NOT cross the portal boundary:
// document.body styles won't inherit into the portal content
// unless you explicitly apply themSuspense и ленивая загрузка
React.lazy и Suspense
React.lazy динамически импортирует компонент, создавая отдельный bundle, загружаемый по требованию (разделение кода). Оборачивайте ленивые компоненты в <Suspense> с fallback (состояние загрузки), показываемым во время скачивания chunk. Это уменьшает начальный размер bundle — пользователи скачивают код только для посещаемых страниц. Каждый вызов lazy() создаёт отдельный chunk. Для разделения по маршрутам лениво загружайте каждый компонент страницы. Fallback может быть любым React-узлом (спиннер, скелетон, текст). Suspense может оборачивать несколько ленивых компонентов — fallback показывается, пока все не готовы.
import { lazy, Suspense } from "react";
// Lazy-load component (code-split)
const Dashboard = lazy(() => import("./Dashboard"));
const Settings = lazy(() => import("./Settings"));
function App() {
return (
<Suspense fallback={<div>Loading page...</div>}>
<nav>
<button onClick={() => setPage("dash")}>Dashboard</button>
<button onClick={() => setPage("settings")}>Settings</button>
</nav>
{page === "dash" && <Dashboard />}
{page === "settings" && <Settings />}
</Suspense>
);
}Вложенный Suspense
Вложенные границы Suspense создают эффект «очистки», когда контент раскрывается прогрессивно по мере загрузки каждого chunk. Внешний Suspense показывает свой fallback первым; по мере загрузки внутренних компонентов они раскрываются независимо. Это предотвращает блокировку всей страницы одним медленным компонентом. Размещайте границы Suspense стратегически: вокруг страниц уровня маршрута (крупно), вокруг основных секций (средне), вокруг независимых виджетов (точно). Слишком много границ создают рваную загрузку; слишком мало — долгие ожидания. Ключ — соответствие границ воспринимаемым пользователем единицам контента.
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<MainSkeleton />}>
<MainContent /> {/* loads first */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* loads independently, doesn't block MainContent */}
</Suspense>
</Suspense>
<Sidebar />
</Suspense>
// Suspense "peeling" effect:
// 1. PageSkeleton shows
// 2. Header + Sidebar load → PageSkeleton peels away
// 3. MainSkeleton shows until MainContent loads
// 4. MainContent shows, CommentsSkeleton shows
// 5. Comments load → everything visibleLazy с границами ошибок
Ленивая загрузка может завершиться неу дачей (сетевые проблемы, развёртывания, аннулирующие URL chunk'ов). Границы ошибок перехватывают эти ошибки и показывают запасной UI. Всегда оборачивайте Suspense + lazy в ErrorBoundary. componentDidCatch логирует ошибки для мониторинга. Для логики повторных попыток можно сбросить состояние ErrorBoundary или перезагрузить страницу. Распространённый паттерн — кнопка повторной попытки, повторно импортирующая chunk. Без границ ошибок неудачная загрузка chunk роняет всё приложение. Это критично для production — надёжность сети никогда не 100%.
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
console.error("Chunk load failed:", error, info);
}
render() {
if (this.state.hasError) {
return (
<div>
<p>Failed to load. {this.state.error.message}</p>
<button onClick={() => window.location.reload()}>
Retry
</button>
</div>
);
}
return this.props.children;
}
}
// Wrap lazy components: network failures need handling
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<LazyComponent />
</Suspense>
</ErrorBoundary>Получение данных с Suspense
Хук use() React 19 включает Suspense для получения данных. В отличие от useEffect, use() приостанавливает компонент, пока промис не разрешится — ближайшая граница Suspense показывает свой fallback. Несколько вызовов use() в одном компоненте разрешаются параллельно (параллельное получение). Это устраняет ручное управление состоянием загрузки. Промис можно кэшировать вне React для предотвращения повторного получения при перерендере. Примечание: use() можно вызывать только в рендере или внутри хуков. Для React 18 используйте библиотеки вроде React Query или SWR, интегрирующиеся с Suspense.
// React 18+ Suspense for data fetching (experimental)
import { use } from "react"; // React 19+
// Wrap a promise with use()
function UserProfile({ userId }) {
// 'use' suspends until the promise resolves
const user = use(fetchUser(userId));
return <div>{user.name}</div>;
}
function fetchUser(id) {
return fetch(`/api/users/${id}`).then((r) => r.json());
}
// Parent provides Suspense boundary
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile userId={1} />
</Suspense>
);
}
// Concurrent: multiple suspends resolve together
function Dashboard() {
const user = use(fetchUser(1));
const posts = use(fetchPosts(user.id));
// Both fetch in parallel, Suspense shows until all resolve
return <div>{user.name}: {posts.length} posts</div>;
}Suspense List (оркестрация)
SuspenseList оркеструет порядок раскрытия нескольких границ Suspense. revealOrder='forwards' показывает элементы по порядку (элемент 2 не раскроется, пока не готов элемент 1, даже если 2 загрузится первым) — предотвращает скачки контента. 'together' ждёт все перед раскрытием. 'backwards' раскрывает снизу вверх. tail='collapsed' скрывает состояния загрузки для ещё не начатых элементов; 'hidden' скрывает все fallback. Полезно для лент и списков, где важен порядок. Примечание: SuspenseList был экспериментальным, и его API может измениться — проверьте текущие документы React на доступность.
// SuspenseList controls reveal order of multiple Suspense
import { SuspenseList, Suspense } from "react";
function Article({ id }) {
const data = use(fetchArticle(id));
return <article>{data.title}</article>;
}
function Feed() {
return (
<SuspenseList revealOrder="forwards" tail="collapsed">
{/* "forwards": reveals top-to-bottom as they load */}
{/* "together": waits for all, reveals together */}
{/* "backwards": reveals bottom-to-top */}
<Suspense fallback={<Skeleton />}>
<Article id={1} />
</Suspense>
<Suspense fallback={<Skeleton />}>
<Article id={2} />
</Suspense>
<Suspense fallback={<Skeleton />}>
<Article id={3} />
</Suspense>
</SuspenseList>
);
}React Router
Базовая маршрутизация
React Router v6 использует <BrowserRouter> как корень, <Routes> для определения сопоставления маршрутов и <Route> с prop element (не component). <Link> создаёт навигационные ссылки, использующие History API (без перезагрузки страницы). Динамические сегменты (:id) доступны через useParams(). path='*' — catch-all для 404. Маршруты сопоставляются по лучшему совпадению, а не по порядку. Для URL-параметров поиска (?q=search) используйте useSearchParams(). BrowserRouter требует настройки сервера для отдачи index.html для всех маршрутов (SPA fallback).
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/users/123">User 123</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/users/:id" element={<UserPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
function UserPage() {
const { id } = useParams();
return <h1>User ID: {id}</h1>;
}Вложенные маршруты и Outlet
Вложенные маршруты создают иерархии макетов. Element родительского маршрута должен включать <Outlet />, где рендерятся дочерние маршруты. Index-маршрут рендерится на пути родителя. Глубоко вложенные маршруты (users/:id) создают вложенные макеты — макет Users оборачивает UserDetail. Это мощно для дашбордов с постоянными боковыми панелями/заголовками. useOutlet() даёт доступ к дочернему элементу. URL /users/123 рендерит Layout → Users → UserDetail, каждый вносит свой макет. Это заменяет ручной условный рендеринг макетов.
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="users" element={<Users />}>
<Route path=":id" element={<UserDetail />} />
</Route>
</Route>
</Routes>
</BrowserRouter>
);
}
function Layout() {
return (
<div>
<nav>Navigation here</nav>
<Outlet /> {/* Child routes render here */}
</div>
);
}
function Users() {
return (
<div>
<h2>Users</h2>
<Outlet /> {/* Nested :id route renders here */}
</div>
);
}Навигация и перенаправления
useNavigate возвращает функцию для программной навигации. navigate('/path', { replace: true }) заменяет историю (без кнопки назад). Передавайте state для переноса данных на следующий маршрут (например, куда вернуться после входа). <Navigate> — декларативный компонент перенаправления — используйте в рендере для охранных условий auth. NavLink предоставляет isActive для стилизации активных ссылок. useLocation даёт текущий URL, pathname, search, hash и state. Для перенаправлений после действий (отправка формы) используйте navigate. Для условных перенаправлений в рендере — <Navigate>.
import { useNavigate, Navigate, NavLink, useLocation } from "react-router-dom";
function Login() {
const navigate = useNavigate();
const location = useLocation();
const handleLogin = async () => {
await auth.login();
// Redirect to intended page or home
const from = location.state?.from || "/";
navigate(from, { replace: true });
};
return <button onClick={handleLogin}>Login</button>;
}
// Declarative redirect
function ProtectedRoute({ user, children }) {
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
// NavLink: active styling
<NavLink to="/about" className={({ isActive }) =>
isActive ? "nav-active" : "nav"
}>
About
</NavLink>Loaders и загрузка данных
React Router v6.4+ (data router) добавляет loaders (запускаются до рендера маршрута) и actions (обрабатывают отправку форм). useLoaderData() обращается к данным loader — больше не нужен useEffect для получения данных маршрута. Loaders запускаются параллельно для вложенных маршрутов. errorElement перехватывает ошибки из loaders/actions. Actions обрабатывают отправку форм через <Form method='post'> — useActionData() возвращает результат. Этот паттерн (вдохновлённый Remix) совместного размещения data-логики с маршрутами. Data API требуют createBrowserRouter/createHashRouter, а не <BrowserRouter>.
import { createBrowserRouter, RouterProvider } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/users/:id",
element: <UserPage />,
loader: async ({ params }) => {
const res = await fetch(`/api/users/${params.id}`);
if (!res.ok) throw new Response("Not found", { status: 404 });
return res.json();
},
errorElement: <ErrorPage />,
},
]);
function UserPage() {
const user = useLoaderData(); // data from loader
return <h1>{user.name}</h1>;
}
// Action for form submissions
{
path: "/users/new",
element: <NewUser />,
action: async ({ request }) => {
const formData = await request.formData();
const res = await fetch("/api/users", {
method: "POST",
body: formData,
});
return redirect(`/users/${res.id}`);
},
}
function App() {
return <RouterProvider router={router} />;
}Охрана маршрутов и защищённые маршруты
Охрана маршрутов защищает маршруты на основе состояния auth или ролей. RequireAuth перенаправляет неаутентифицированных пользователей на вход, сохраняя предполагаемое место назначения в location.state для перенаправления после входа. RequireRole добавляет управление доступом на основе ролей. Композируйте охрану, оборачивая их (RequireAuth > RequireRole > component). Для маршрутов макета также можно использовать element={<RequireAuth><Outlet/></RequireAuth>} для защиты всех дочерних маршрутов сразу. Всегда проверяйте auth и на сервере — клиентские охраны для UX, а не безопасности. Паттерн масштабируется на любое условие: подписка, feature flags и т. д.
function RequireAuth({ children }) {
const { user } = useAuth();
const location = useLocation();
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="dashboard" element={
<RequireAuth><Dashboard /></RequireAuth>
} />
<Route path="admin" element={
<RequireAuth><RequireRole role="admin"><Admin /></RequireRole></RequireAuth>
} />
</Route>
</Routes>
</BrowserRouter>
);
}
function RequireRole({ role, children }) {
const { user } = useAuth();
if (user?.role !== role) return <Navigate to="/forbidden" />;
return children;
}Управление состоянием (Context и Redux)
Паттерн Context API
Context предоставляет глобальное состояние без prop drilling. Создайте context, оберните потребителей в Provider и обращайтесь через useContext. Пользовательский хук useAuth добавляет проверку ошибок и является рекомендуемой поверхностью API. Context идеален для низкочастотных обновлений (auth, тема, локаль). Для высокочастотных изменений состояния Context вызывает перерендер всех потребителей при каждом изменении — используйте useReducer для сложного состояния или разделяйте контексты. Всегда размещайте провайдер рядом с управляемым им состоянием. Значение Context мемоизируется с useMemo/useCallback, если содержит функции.
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = async (credentials) => {
const user = await api.login(credentials);
setUser(user);
};
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be inside AuthProvider");
return ctx;
}
// Usage
function Navbar() {
const { user, logout } = useAuth();
return user
? <button onClick={logout}>Logout {user.name}</button>
: <Link to="/login">Login</Link>;
}
// Wrap app: <AuthProvider><App /></AuthProvider>useReducer + Context
useReducer + Context — рекомендуемый паттерн для сложного глобального состояния без внешних библиотек. Редьюсер централизует логику состояния (предсказуемые, тестируемые переходы). Провайдер мемоизирует значение для предотвращения ненужных перерендеров. Производные значения (total) вычисляются в useMemo. Этот паттерн обрабатывает корзину, состояние формы, многошаговые мастера и т. д. Для действительно сложных приложений с middleware, отладкой путешествия во времени или множеством независимых слайсов рассмотрите Redux Toolkit или Zustand. Но для большинства приложений useReducer + Context достаточно и не имеет зависимостей.
const CartContext = createContext();
function cartReducer(state, action) {
switch (action.type) {
case "ADD":
const existing = state.find((i) => i.id === action.item.id);
if (existing) {
return state.map((i) =>
i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i
);
}
return [...state, { ...action.item, qty: 1 }];
case "REMOVE":
return state.filter((i) => i.id !== action.id);
case "CLEAR":
return [];
default:
return state;
}
}
function CartProvider({ children }) {
const [items, dispatch] = useReducer(cartReducer, []);
const value = useMemo(() => ({
items,
total: items.reduce((s, i) => s + i.price * i.qty, 0),
addItem: (item) => dispatch({ type: "ADD", item }),
removeItem: (id) => dispatch({ type: "REMOVE", id }),
}), [items]);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}Основы Redux Toolkit
Redux Toolkit (RTK) — современный рекомендуемый способ использования Redux. createSlice авто-генерирует создатели действий и редьюсеры. Он использует Immer внутри, поэтому вы «мутируете» состояние напрямую (state.value += 1), а Immer производит иммутабельное обновление. configureStore настраивает store с разумными значениями по умолчанию (Redux DevTools, thunk-middleware). useSelector читает состояние; useDispatch диспатчит действия. RTK устраняет шаблонность Redux (без switch, без констант типов действий). Для асинхронной логики используйте createAsyncThunk. RTK Query (включён) обрабатывает получение данных и кэширование.
import { configureStore, createSlice } from "@reduxjs/toolkit";
import { useSelector, useDispatch } from "react-redux";
const counterSlice = createSlice({
name: "counter",
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1; }, // Immer: mutate safely
decrement: (state) => { state.value -= 1; },
addBy: (state, action) => { state.value += action.payload; },
},
});
const store = configureStore({
reducer: { counter: counterSlice.reducer },
});
export const { increment, decrement, addBy } = counterSlice.actions;
// Usage in component
function Counter() {
const count = useSelector((state) => state.counter.value);
const dispatch = useDispatch();
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch(increment())}>+</button>
<button onClick={() => dispatch(addBy(5))}>+5</button>
</div>
);
}Zustand (лёгкая альтернатива)
Zustand — минимальная библиотека управления состоянием: без провайдеров, без шаблонности. Создайте store через create(), обращайтесь через хуки с функциями-селекторами. Селекторы предотвращают перерендеры: перерендеривают только компоненты, использующие изменившийся слайс. Это решает проблему перерендеров Context без сложности Redux. Для объектных селекторов (возвращающих {a, b}) используйте поверхностное сравнение для предотвращения ненужных перерендеров. Zustand поддерживает middleware (persist, devtools, immer). Идеален для малых и средних приложений, где Redux избыточен, а Context вызывает слишком много перерендеров. API крошечный, но мощный.
import { create } from "zustand";
const useStore = create((set, get) => ({
count: 0,
user: null,
increment: () => set((state) => ({ count: state.count + 1 })),
setUser: (user) => set({ user }),
reset: () => set({ count: 0, user: null }),
// Access other state with get()
doubleCount: () => get().count * 2,
}));
// Usage: select only what you need (prevents re-renders)
function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
// Multiple selections
function Profile() {
const { user, setUser } = useStore(
(state) => ({ user: state.user, setUser: state.setUser })
);
// Use shallow comparison for object selectors
// import { shallow } from "zustand/shallow";
// useStore(selector, shallow);
}React Query (серверное состояние)
React Query (TanStack Query) управляет серверным состоянием — данными, полученными из API. Он обрабатывает кэширование, фоновое пере получение, устаревшие данные, оптимистичные обновления и пагинацию автоматически. queryKey идентифицирует кэшированные данные (как ключ кэша). staleTime контролирует, как долго данные считаются свежими. invalidateQueries после мутаций пере получает зависимые запросы. В отличие от Redux (клиентское состояние), React Query целенаправленно создан для асинхронных серверных данных. Он устраняет ручные состояния loading/error, получение через useEffect и управление кэшем. Для большинства приложений React Query + локальное состояние (useState/useReducer) полностью заменяет Redux.
import { useQuery, useMutation, QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<Users />
</QueryClientProvider>
);
}
function Users() {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["users"],
queryFn: () => fetch("/api/users").then((r) => r.json()),
staleTime: 60000, // fresh for 60s
refetchOnWindowFocus: true,
});
const mutation = useMutation({
mutationFn: (newUser) =>
fetch("/api/users", { method: "POST", body: JSON.stringify(newUser) }),
onSuccess: () => queryClient.invalidateQueries(["users"]),
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
{data.map((u) => <div key={u.id}>{u.name}</div>)}
<button onClick={() => mutation.mutate({ name: "New" })}>Add</button>
</div>
);
}Тестирование (React Testing Library)
Базовый тест компонента
React Testing Library (RTL) тестирует компоненты так, как пользователи взаимодействуют с ними — по role, label и тексту, а не по деталям реализации. getByRole — предпочтительный запрос (тестирует и доступность). userEvent симулирует реальные взаимодействия пользователя (ввод, клики) точнее, чем fireEvent. Тесты должны избегать тестирования внутреннего состояния; вместо этого проверяйте видимый вывод и поведение. Если не можете запросить по role, используйте getByLabelText, getByText или getByDisplayValue. Избегайте getByTestId, если не необходимо. Этот подход делает тесты устойчивыми к рефакторингу — они тестируют то, что видят и делают пользователи.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";
test("counter increments on click", async () => {
const user = userEvent.setup();
render(<Counter />);
// Find by accessible role (not test-id)
expect(screen.getByRole("heading")).toHaveTextContent("0");
await user.click(screen.getByRole("button", { name: /increment/i }));
expect(screen.getByRole("heading")).toHaveTextContent("1");
});
test("displays error for invalid input", async () => {
const user = userEvent.setup();
render(<Form />);
await user.type(screen.getByLabelText(/email/i), "not-an-email");
await user.click(screen.getByRole("button", { name: /submit/i }));
expect(screen.getByRole("alert")).toHaveTextContent(/invalid email/i);
});Тестирование хуков
renderHook тестирует пользовательские хуки изолированно. result.current содержит возвращаемое значение хука. Все обновления состояния должны быть обёрнуты в act() для обеспечения синхронной обработки React. rerender позволяет тестировать эффекты, зависящие от изменяющихся props. Для асинхронных хуков (useEffect с fetch) используйте waitFor или запросы findBy (которые ожидают обновлений). Прямое тестирование хуков быстрее и сфокусированнее, чем тестирование через компонент. Однако также тестируйте хуки через интеграционные тесты компонентов для проверки реального использования. renderHook доступен в @testing-library/react v13+.
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "./useCounter";
test("useCounter increments and decrements", () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
// Wrap state updates in act()
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
act(() => result.current.reset());
expect(result.current.count).toBe(0);
});
// Testing with initial props that change
test("useEffect runs on dependency change", () => {
const { result, rerender } = renderHook(
({ id }) => useFetchUser(id),
{ initialProps: { id: 1 } }
);
rerender({ id: 2 });
// Effect re-ran with new id
});Асинхронное тестирование и мокинг
MSW (Mock Service Worker) перехватывает сетевые запросы на уровне service worker — тесты используют реальный fetch(), но получают замокированные ответы. Это реалистичнее, чем мокирование fetch напрямую. setupServer для Node (Jest), setupWorker для браузера. beforeAll/afterAll управляют сервером. server.use() переопределяет обработчики для каждого теста. Запросы findBy (асинхронные) ожидают появления элементов — используйте для асинхронного рендеринга. queryBy возвращает null, если не найдено (для утверждения отсутствия). waitFor опрашивает условие. MSW также можно использовать для мокинга разработки и Storybook.
import { render, screen, waitFor } from "@testing-library/react";
import { rest } from "msw";
import { setupServer } from "msw/node";
import { UserProfile } from "./UserProfile";
// Mock API with MSW (Mock Service Worker)
const server = setupServer(
rest.get("/api/users/:id", (req, res, ctx) => {
return res(ctx.json({ id: 1, name: "Alice" }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("displays user after fetch", async () => {
render(<UserProfile id={1} />);
// findBy waits for async update
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(screen.queryByText("Loading")).not.toBeInTheDocument();
});
test("shows error on fetch failure", async () => {
server.use(
rest.get("/api/users/:id", (req, res, ctx) =>
res(ctx.status(500))
)
);
render(<UserProfile id={1} />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});Тестирование Context и провайдеров
Создайте пользовательскую утилиту render, оборачивающую компоненты необходимыми провайдерами (Theme, Auth, Router и т. д.). Это избегает повторения настройки провайдеров в каждом тесте. Ре-экспортируйте функции RTL из файла test-utils, чтобы тесты импортировали оттуда. Для тестирования роутера используйте MemoryRouter (не BrowserRouter) с initialEntries для установки начального URL — не нужна реальная история браузера. Для Redux оберните в тестовый Provider с реальным или мок-Store. Этот паттерн держит тесты чистыми и гарантирует, что все компоненты имеют необходимый context. Это стандартная настройка для любой инфраструктуры тестирования React.
// Custom render that wraps with providers
import { render } from "@testing-library/react";
import { ThemeProvider } from "./ThemeProvider";
function customRender(ui, { theme = "light", ...options } = {}) {
function Wrapper({ children }) {
return <ThemeProvider initialTheme={theme}>{children}</ThemeProvider>;
}
return render(ui, { wrapper: Wrapper, ...options });
}
// Re-export everything
export * from "@testing-library/react";
export { customRender as render };
// In test files, import from your test-utils:
// import { render, screen } from "../test-utils";
test("button uses theme color", () => {
customRender(<Button>Click</Button>, { theme: "dark" });
expect(screen.getByRole("button")).toHaveClass("btn-dark");
});
// Testing with router
import { MemoryRouter } from "react-router-dom";
render(
<MemoryRouter initialEntries={["/users/123"]}>
<App />
</MemoryRouter>
);Тестирование событий и взаимодействий
userEvent.setup() создаёт экземпляр user для реалистичных взаимодействий: type (символ за символом), click, tab, keyboard (с кодами клавиш вроде {Escape}, {Enter}), selectOptions, upload и др. Всегда ожидайте пользовательские взаимодействия — они асинхронны. Тестирование навигации с клавиатуры критично для доступности. Для тестирования форм заполняйте все поля и проверяйте, что onSubmit получает корректные данные. userEvent предпочтительнее fireEvent, так как симулирует реальное поведение браузера (focus, blur, input-события в правильном порядке). Тестируйте полный пользовательский поток, а не отдельные обработчики событий.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("form submission flow", async () => {
const onSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
// Fill form fields
await user.type(screen.getByLabelText(/email/i), "[email protected]");
await user.type(screen.getByLabelText(/password/i), "password123");
// Submit
await user.click(screen.getByRole("button", { name: /login/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: "[email protected]",
password: "password123",
});
});
test("keyboard navigation", async () => {
const user = userEvent.setup();
render(<Modal />);
await user.tab(); // focus first element
expect(screen.getByRole("button", { name: /close/i })).toHaveFocus();
await user.keyboard("{Escape}"); // press Escape
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});TypeScript + React
Типизация props компонента
TypeScript с React обеспечивает типобезопасность props. Используйте интерфейсы или псевдонимы типов для props. Опциональные props используют ?. Union-типы (variant) ограничивают значения. React.ReactNode принимает любой рендеримый контент (строки, элементы, массивы). Расширение HTML-атрибутов (React.InputHTMLAttributes) позволяет компоненту принимать все нативные атрибуты (placeholder, onChange и т. д.), добавляя пользовательские props. Spread {...rest} передаёт оставшиеся атрибуты нативному элементу. Этот паттерн создаёт типобезопасные, гибкие компоненты. Всегда экспортируйте типы props, чтобы потребители могли ссылаться на них.
// Basic props
interface ButtonProps {
text: string;
onClick: () => void;
variant?: "primary" | "secondary"; // union type
disabled?: boolean;
}
function Button({ text, onClick, variant = "primary", disabled }: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
disabled={disabled}
>
{text}
</button>
);
}
// Children prop
interface CardProps {
title: string;
children: React.ReactNode; // any renderable content
}
// Extending HTML attributes
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
function Input({ label, error, ...rest }: InputProps) {
return (
<label>
{label}
<input {...rest} />
{error && <span className="error">{error}</span>}
</label>
);
}Хуки с TypeScript
TypeScript добавляет типобезопасность хукам. useState<T> указывает тип состояния; useState<T | null>(null) для состояния, допускающего null. useRef<T>(null) типизирует ref — current имеет тип T | null. Для useContext определите тип context и выбрасывайте исключение, если undefined (чтобы потребители получали не-undefined тип). Для useReducer типизируйте Action как discriminated union — switch по action.type сужает тип в каждом case, обеспечивая типобезопасный доступ к payload. Эти паттерны устраняют ошибки выполнения от доступа к undefined и некорректных payload действий.
// useState with types
const [count, setCount] = useState<number>(0);
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<string[]>([]);
// useRef
const inputRef = useRef<HTMLInputElement>(null);
// Access: inputRef.current?.focus()
// useContext
interface ThemeContextType {
theme: "light" | "dark";
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be inside ThemeProvider");
return ctx; // ctx is now ThemeContextType, not undefined
}
// useReducer
type Action = { type: "increment" } | { type: "set"; value: number };
const [state, dispatch] = useReducer((state: number, action: Action) => {
switch (action.type) {
case "increment": return state + 1;
case "set": return action.value;
}
}, 0);Дженерик-компоненты
Дженерик-компоненты и хуки работают с любым типом данных, сохраняя типобезопасность. Параметр типа <T> выводится из prop items, поэтому renderItem и keyExtractor автоматически получают корректный тип. Так TypeScript воссоздаёт дженерик утилитарные компоненты (List, Table, Select) с полной типобезопасностью. Дженерик-хуки (useArray<T>) аналогично сохраняют типы через операции. Ключевое понимание: TypeScript выводит T из использования, поэтому потребителям редко нужно указывать его явно. Этот паттерн необходим для построения переиспользуемых, типобезопасных библиотек компонентов.
// Generic component: works with any type
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Usage: TypeScript infers T from items
<List
items={[{ id: "1", name: "Alice" }, { id: "2", name: "Bob" }]}
renderItem={(user) => <span>{user.name}</span>}
keyExtractor={(user) => user.id}
/>
// Generic hook
function useArray<T>(initial: T[]) {
const [array, setArray] = useState(initial);
const push = (item: T) => setArray((prev) => [...prev, item]);
return { array, push, setArray };
}Типы событий и refs
Типы событий React специфичны: ChangeEvent для input'ов, FormEvent для форм, MouseEvent для кликов. Каждый дженерик по типу элемента (e.target корректно типизирован). forwardRef с TypeScript требует два параметра типа: тип ref и тип props. forwardRef нужен, когда компонент должен раскрыть ref DOM-элементу (для фокуса, измерений и т. д.). Всегда устанавливайте displayName для компонентов forwardRef/memo для лучшей отладки DevTools. React 19 позволяет ref как обычный prop, уменьшая необходимость в forwardRef, но он всё ещё распространён в существующем коде.
// Event types
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
console.log(e.target.value); // string
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
}
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
console.log(e.clientX, e.clientY);
}
// forwardRef with TypeScript
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, ...props }, ref) => (
<label>
{label}
<input ref={ref} {...props} />
</label>
)
);
Input.displayName = "Input";
// Usage: const ref = useRef<HTMLInputElement>(null);
// <Input ref={ref} label="Email" />Утилитарные типы для props
Утилитарные типы TypeScript мощны для композиции props. Pick выбирает определённые props (для подкомпонентов). Omit исключает props (для замены поведения). Partial делает все props опциональными (для паттернов props по умолчанию). ComponentProps<typeof Component> извлекает тип props компонента — полезно для оборачивания/расширения существующих компонентов. Record<K, V> создаёт тип, сопоставляющий ключи со значениями (отлично для карт variant→class). Эти утилиты обеспечивают DRY, типобезопасные определения props без повторения интерфейсов. Освойте их для написания поддерживаемого кода React + TypeScript.
// Pick: select specific props
interface ButtonProps {
text: string;
onClick: () => void;
color: string;
size: "sm" | "md" | "lg";
}
type IconButtonProps = Pick<ButtonProps, "onClick" | "size"> & {
icon: React.ReactNode;
};
// Omit: exclude specific props
type LinkButtonProps = Omit<ButtonProps, "onClick"> & {
href: string;
};
// Partial: all props optional (for defaults)
type DefaultProps = Partial<ButtonProps>;
// ComponentProps: extract props from existing component
type MyButtonProps = React.ComponentProps<typeof Button> & {
variant?: "custom";
};
// ReturnType: type of a function's return
type User = ReturnType<typeof fetchUser>;
// Record for prop maps
type ButtonVariants = Record<"primary" | "danger" | "ghost", string>;Concurrent-функции (useTransition, useDeferredValue)
useTransition
useTransition помечает обновление состояния как не срочное (переход). Срочные обновления (значение input) рендерятся немедленно для отзывчивости; не срочные (фильтрация 10 000 элементов) могут быть прерваны, если пользователь печатает снова. isPending указывает, что переход выполняется (показывайте тонкий индикатор загрузки). Это предотвращает зависание UI при дорогих рендерах. Ключевое понимание: React может прерывать и отбрасывать устаревшие переходы, сохраняя отзывчивость UI. Используйте для фильтрации поиска, переключения вкладок и любого обновления состояния, вызывающего тяжёлый рендеринг. Не оборачивайте срочные обновления (ввод, клики) в переходы.
import { useTransition, useState } from "react";
function SearchResults() {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const handleSearch = (value) => {
setQuery(value); // urgent: update input immediately
startTransition(() => {
// non-urgent: heavy filtering can be interrupted
const filtered = heavyFilter(allItems, value);
setResults(filtered);
});
};
return (
<div>
<input value={query} onChange={(e) => handleSearch(e.target.value)} />
{isPending && <span>Updating...</span>}
<ul>{results.map((r) => <li key={r.id}>{r.name}</li>)}</ul>
</div>
);
}useDeferredValue
useDeferredValue — декларативный аналог useTransition. Возвращает отложенную копию значения, обновляющуюся с более низким приоритетом. Input обновляется немедленно (срочно); дорогой список перерендеривает с отложенным значением (не срочно). React.memo на компоненте Results критичен — он предотвращает перерендер при каждом нажатии клавиши, только когда изменяется deferredQuery. isStale (сравнение текущего и отложенного) позволяет показывать визуальный индикатор (приглушение, спиннер). Используйте useDeferredValue, когда не контролируете обновление состояния (например, значение из props). Используйте useTransition, когда контролируете обновление.
function Search() {
const [query, setQuery] = useState("");
// query updates immediately; deferredQuery lags behind
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results query={deferredQuery} isStale={isStale} />
</div>
);
}
// Memoize the expensive component so it only re-renders
// when deferredQuery changes (not on every keystroke)
const Results = React.memo(function Results({ query, isStale }) {
const items = expensiveSearch(query); // heavy computation
return (
<div style={{ opacity: isStale ? 0.5 : 1 }}>
{items.map((i) => <div key={i.id}>{i.name}</div>)}
</div>
);
});useOptimistic (React 19)
useOptimistic (React 19) реализует оптимистичные обновления — UI обновляется немедленно с ожидаемым результатом, затем согласовывается с фактическим ответом сервера. Оптимистичное состояние показывается во время асинхронной операции; когда реальные данные приходят (компонент перерендеривает с новыми props), оптимистичное значение автоматически заменяется. Если операция завершается неудачей, оптимистичное обновление просто откатывается при следующем рендере с неизменёнными props. Это устраняет ручную логику оптимистичных обновлений (отслеживание состояния ожидания, откат при ошибке). Пометьте ожидающие элементы (pending: true) для показа индикаторов загрузки. Идеально для лайков, комментариев и переключателей.
import { useOptimistic } from "react";
function ThumbsUp({ likes, addLike }) {
// Optimistic state: updates immediately, reverts on error
const [optimisticLikes, addOptimisticLike] = useOptimistic(
likes,
(state, newLike) => [...state, newLike]
);
const handleClick = async () => {
const newLike = { id: Date.now(), pending: true };
addOptimisticLike(newLike); // instant UI update
try {
await addLike(newLike); // actual API call
} catch {
// Reverts automatically on re-render with real data
}
};
return (
<div>
<button onClick={handleClick}>👍 {optimisticLikes.length}</button>
{optimisticLikes.some((l) => l.pending) && <span>Saving...</span>}
</div>
);
}use (хук React 19)
use() — новый хук React 19, читающий context или промисы. В отличие от useContext, use() можно вызывать условно (внутри if, циклов) — он не имеет ограничений rules-of-hooks. Для промисов use() приостанавливает компонент, пока промис не разрешится (требуется граница Suspense). Промис создаётся в родителе и передаётся как prop — это начинает получение во время рендера (не в useEffect), позволяя каскадам начинаться раньше. Тот же промис можно передать нескольким компонентам (дедупликация). use() заполняет пробел между синхронным context и асинхронными данными.
import { use } from "react";
// Read context with use (works in conditions!)
function Theme() {
// Unlike useContext, use() can be inside conditions
if (showTheme) {
const theme = use(ThemeContext); // conditional context!
return <div style={{ background: theme.color }} />;
}
return null;
}
// Read promises with use (Suspense integration)
function UserProfile({ userPromise }) {
// Suspends until promise resolves
const user = use(userPromise);
return <h1>{user.name}</h1>;
}
// Parent passes promise (starts fetching during render)
function App() {
const userPromise = fetchUser(); // starts immediately
return (
<Suspense fallback={<Loading />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}Паттерны concurrent-рендеринга
Concurrent-паттерны: useTransition для не срочных переключений вкладок (сохраняет навигацию отзывчивой, пока тяжёлый контент рендерится). useSyncExternalStore безопасно подписывается на внешние хранилища (API браузера, Redux, Zustand) в concurrent-режиме — предоставляет функцию снимка для клиента и сервера (SSR-safe). Никогда не используйте внешнее изменяемое состояние напрямую в рендере (риск tearing); всегда через useSyncExternalStore. Три аргумента: subscribe (возвращает очистку), getSnapshot (текущее значение), getServerSnapshot (SSR-начальное значение). Это обеспечивает согласованные чтения во время concurrent-рендеринга. Библиотеки вроде Redux и Zustand используют это внутри.
// Pattern 1: Deferred search with transition
function App() {
const [tab, setTab] = useState("home");
const [isPending, startTransition] = useTransition();
return (
<>
<nav>
<button
onClick={() => startTransition(() => setTab("analytics"))}
disabled={isPending}
>
{isPending ? "Loading..." : "Analytics"}
</button>
</nav>
{tab === "home" && <Home />}
{tab === "analytics" && <HeavyAnalytics />}
</>
);
}
// Pattern 2: useSyncExternalStore for external state
function useOnlineStatus() {
return useSyncExternalStore(
(callback) => {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
},
() => navigator.onLine, // client snapshot
() => true // server snapshot (SSR)
);
}Углублённое изучение Context API
createContext и Provider
createContext создаёт объект context со значением по умолчанию, используемым, когда Provider не найден. Prop value Provider потребляется всеми потомками. Оборачивайте значение в useCallback/useMemo для предотвращения ненужных перерендеров.
const ThemeContext = React.createContext({ theme: 'light', toggle: () => {} });
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggle = useCallback(() => setTheme((t) => (t === 'light' ? 'dark' : 'light')), []);
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}useContext
useContext читает ближайшее значение Provider и перерендерит компонент при изменении этого значения. Вкладывайте несколько Provider для разных задач. Разделяйте контексты по частоте обновления для производительности.
function ThemedButton() {
const { theme, toggle } = useContext(ThemeContext);
return (
<button onClick={toggle} style={{ background: theme === 'dark' ? '#333' : '#eee' }}>
Toggle Theme
</button>
);
}Context с редьюсером
Сочетание useReducer с Context создаёт глобальное хранилище без Redux. Редьюсер централизует логику состояния; Context распределяет состояние и dispatch. Потребители могут диспатчить действия без prop drilling.
const StoreContext = React.createContext(null);
function storeReducer(state, action) {
switch (action.type) {
case 'add': return { items: [...state.items, action.item] };
case 'remove': return { items: state.items.filter((_, i) => i !== action.index) };
default: return state;
}
}
function StoreProvider({ children }) {
const [state, dispatch] = useReducer(storeReducer, { items: [] });
return <StoreContext.Provider value={{ state, dispatch }}>{children}</StoreContext.Provider>;
}Оптимизация рендеров Context
Когда состояние и dispatch находятся в одном context, каждое изменение состояния перерендерит всех потребителей. Разделение их означает, что компоненты только-dispatch никогда не перерендеривают при изменениях состояния. dispatch из useReducer стабилен.
const StateContext = React.createContext(null);
const DispatchContext = React.createContext(null);
function Provider({ children }) {
const [state, dispatch] = useReducer(reducer, initial);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}Пользовательский хук для Context
Оборачивание useContext в пользовательский хук даёт чистый API и понятную ошибку при отсутствии Provider. Экспортируйте и Provider, и хук. Это рекомендуемый способ потребления context.
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}useReducer
Базовый useReducer
useReducer — альтернатива useState для сложной логики состояния. Редьюсер — чистая функция: (state, action) => newState. dispatch стабилен, поэтому мож но передавать его ниже, не беспокоясь о перерендерах.
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
case 'reset': return { count: 0 };
default: throw new Error('Unknown action: ' + action.type);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return <button onClick={() => dispatch({ type: 'increment' })}>{state.count}</button>;
}Ленивая инициализация
Третий аргумент useReducer — функция init, запускающаяся один раз при начальном рендере. Полезно, когда начальное состояние дорого вычислить или когда нужно, чтобы reset возвращал вычисленное состояние.
function init(initialCount) {
return { count: initialCount, history: [] };
}
function reducer(state, action) {
switch (action.type) {
case 'increment': return { ...state, count: state.count + 1 };
case 'reset': return init(action.payload);
default: return state;
}
}
const [state, dispatch] = useReducer(reducer, initialCount, init);Сложная форма состояния
useReducer проявляет себя, когда у состояния несколько связанных полей. Каждое действие описывает полный переход состояния, делая логику легче отслеживаемой, чем разбросанные вызовы setState. Держите редьюсер чистым.
const initialState = { users: [], loading: false, error: null, filter: 'all' };
function reducer(state, action) {
switch (action.type) {
case 'fetch-start': return { ...state, loading: true, error: null };
case 'fetch-success': return { ...state, loading: false, users: action.users };
case 'fetch-error': return { ...state, loading: false, error: action.error };
case 'set-filter': return { ...state, filter: action.filter };
default: return state;
}
}Редьюсер с Context
Сочетание useReducer с Context создаёт лёгкое хранилище наподобие Redux. Редьюсер содержит логику; Context распределяет состояние и dispatch. Это рекомендуемый паттерн для состояния уровня приложения в средних приложениях.
function todoReducer(state, action) {
switch (action.type) {
case 'add': return [...state, { id: Date.now(), text: action.text, done: false }];
case 'toggle': return state.map((t) => t.id === action.id ? { ...t, done: !t.done } : t);
case 'delete': return state.filter((t) => t.id !== action.id);
default: return state;
}
}
export function TodoProvider({ children }) {
const [todos, dispatch] = useReducer(todoReducer, []);
return <TodoContext.Provider value={{ todos, dispatch }}>{children}</TodoContext.Provider>;
}Типы действий и паттерны
Определяйте типы действий как константы для избежания опечаток и включения автодополнения IDE. Форма действия { type, payload? } — распространённое соглашение. Для TypeScript определите discriminated union типов действий.
const ACTIONS = { ADD: 'add', UPDATE: 'update', DELETE: 'delete' };
function reducer(state, action) {
switch (action.type) {
case ACTIONS.ADD:
return [...state, { id: action.id, ...action.payload }];
case ACTIONS.UPDATE:
return state.map((item) => item.id === action.id ? { ...item, ...action.payload } : item);
case ACTIONS.DELETE:
return state.filter((item) => item.id !== action.id);
default: return state;
}
}useMemo и useCallback
useMemo
useMemo кэширует результат вычисления и пересчитывает только при изменении зависимостей. Используйте для дорогостоящих вычислений (сортировка, фильтрация больших массивов). Массив зависимостей должен включать всё, что использует колбэк.
function ProductList({ products, filter }) {
const filtered = useMemo(() => {
return products.filter((p) => p.category === filter);
}, [products, filter]);
const sorted = useMemo(() => [...filtered].sort((a, b) => a.price - b.price), [filtered]);
return <ul>{sorted.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}useCallback
useCallback мемоизирует функцию, сохраняя ту же идентичность между рендерами, пока не изменятся зависимости. Это критично при передаче колбэков мемоизированным потомкам — без этого потомок перерендеривает каждый раз.
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => setCount((c) => c + 1), []);
return <MemoizedChild onClick={handleClick} />;
}React.memo
React.memo оборачивает компонент, чтобы он перерендеривал только при изменении props (поверхностное сравнение). Второй аргумент — пользовательский компаратор, возвращающий true для пропуска перерендера. Комбинируйте memo с useCallback/useMemo для props.
const ExpensiveItem = React.memo(function ExpensiveItem({ value, onClick }) {
return <li onClick={onClick}>{value}</li>;
});
// With custom comparison
const DeepChild = React.memo(
({ user }) => <div>{user.name}</div>,
(prev, next) => prev.user.id === next.user.id
);Когда мемоизировать
У мемоизации есть стоимость, которая может превысить экономию. Мемоизируйте только когда: (1) вычисление дорогое, (2) значение передаётся мемоизированному потомку, или (3) значение используется как зависимость в useEffect/useMemo.
// GOOD: expensive computation
const sorted = useMemo(() => heavySort(data), [data]);
// GOOD: callback passed to memoized child
const onSelect = useCallback((id) => setSelected(id), []);
// BAD: cheap operation, no perf issue
const label = useMemo(() => first + ' ' + last, [first, last]);useMemo для ссылочного равенства
useMemo гарантирует, что объекты и массивы сохраняют ту же ссылку между рендерами, что важно при использовании их как зависимостей в useEffect/useMemo/useCallback. Без этого { q: query } создаёт новый объек т при каждом рендере.
function Search({ query }) {
const params = useMemo(() => ({ q: query, limit: 10 }), [query]);
useEffect(() => {
api.search(params).then(setData);
}, [params]); // Without useMemo, this fires every render
}Portals и refs
createPortal
createPortal рендерит children в DOM-узел вне DOM-дерева родительского компонента (обычно document.body). Это необходимо для модалок, тултипов и dropdown'ов, которые должны избежать родительских контекстов наложения.
import { createPortal } from 'react-dom';
function Modal({ open, onClose, children }) {
if (!open) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>{children}</div>
</div>,
document.body
);
}useRef
useRef возвращает изменяемый объект, чей .current сохраняется между рендерами, не вызывая перерендеров. Основное использование — доступ к DOM-узлам. Также хранит изменяемые значения, не влияющие на UI.
function FocusInput() {
const inputRef = useRef(null);
const focus = () => inputRef.current?.focus();
return (
<>
<input ref={inputRef} type="text" />
<button onClick={focus}>Focus</button>
</>
);
}forwardRef
forwardRef позволяет родительскому компоненту передавать ref через компонент-обёртку к дочернему DOM-узлу. Без него React запрещает передачу ref как prop. ref — второй аргумент обёрнутой функции.
const FancyInput = React.forwardRef(function FancyInput({ label, ...props }, ref) {
return (
<label>{label}<input ref={ref} {...props} className="fancy-input" /></label>
);
});useImperativeHandle
useImperativeHandle настраивает экземпляр, раскрываемый родителю через ref — вместо сырого DOM-узла родитель видит только определённые вами методы. Используйте умеренно; предпочитайте декларативные props, когда возможно.
const VideoPlayer = React.forwardRef(function VideoPlayer(props, ref) {
const videoRef = useRef(null);
useImperativeHandle(ref, () => ({
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seek: (time) => { if (videoRef.current) videoRef.current.currentTime = time; },
}));
return <video ref={videoRef} src={props.src} />;
});Refs для изменяемых значений
useRef хранит изменяемые значения, которые не должны вызывать перерендеры — как ID таймеров, экземпляры WebSocket или флаги «смонтирован». Всегда очищайте побочные эффекты в функции очистки useEffect.
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
const start = () => {
if (intervalRef.current) return;
intervalRef.current = setInterval(() => setSeconds((s) => s + 1), 1000);
};
const stop = () => { clearInterval(intervalRef.current); intervalRef.current = null; };
useEffect(() => () => clearInterval(intervalRef.current), []);
return <>{seconds}<button onClick={start}>Start</button><button onClick={stop}>Stop</button></>;
}Границы ошибок
Классовая граница ошибок
Границы ошибок — классовые компоненты, перехватывающие ошибки в дереве дочерних компонентов во время рендеринга. getDerivedStateFromError обновляет состояние для рендеринга запасного UI; componentDidCatch логирует ошибку. Они НЕ перехватывают ошибки в обработчиках событий или асинхронном коде.
class ErrorBoundary extends React.Component {
constructor(props) { super(props); this.state = { hasError: false, error: null }; }
static getDerivedStateFromError(error) { return { hasError: true, error }; }
componentDidCatch(error, info) { console.error('Caught:', error, info); }
render() {
if (this.state.hasError) return this.props.fallback || <h1>Something went wrong.</h1>;
return this.props.children;
}
}Использование границ ошибок
Размещайте границы ошибок стратегически, чтобы падение одной части UI не роняло всё приложение. Гранулярные границы вокруг виджетов позволяют остальному приложению продолжать работать.
function App() {
return (
<ErrorBoundary fallback={<ErrorPage />}>
<Header />
<ErrorBoundary fallback={<SidebarCrash />}><Sidebar /></ErrorBoundary>
<ErrorBoundary fallback={<ContentCrash />}><MainContent /></ErrorBoundary>
</ErrorBoundary>
);
}Сброс состояния ошибки
Границы ошибок остаются в состоянии ошибки, пока их состояние не изменится. Предоставьте кнопку «Попробовать снова», сбрасывающую hasError в false, что позволяет React перерендерить дочерние элементы. Изменение prop key также сбрасывает его.
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) { return { hasError: true, error }; }
reset = () => this.setState({ hasError: false, error: null });
render() {
if (this.state.hasError) return <div><p>Failed.</p><button onClick={this.reset}>Try again</button></div>;
return this.props.children;
}
}Библиотека react-error-boundary
Библиотека react-error-boundary предоставляет отполированную, хук-дружественную границу ошибок без написания класса. FallbackComponent получает error и функцию resetErrorBoundary. resetKeys авто-сбрасывает, когда эти значения изменяются.
import { ErrorBoundary } from 'react-error-boundary';
<ErrorBoundary
FallbackComponent={ErrorFallback}
onError={(error, info) => logError(error, info)}
onReset={() => window.location.reload()}
resetKeys={[location.pathname]}
>
<Routes />
</ErrorBoundary>Асинхронная обработка ошибок
Границы ошибок не перехватывают ошибки в про мисах, setTimeout или обработчиках событий. Чтобы передать асинхронные ошибки границе, сохраните ошибку в состоянии и повторно выбросите её во время рендера. Граница затем перехватит её.
function AsyncComponent() {
const [state, setState] = useState({ data: null, error: null });
useEffect(() => {
let active = true;
fetchData()
.then((data) => { if (active) setState({ data, error: null }); })
.catch((error) => { if (active) setState({ data: null, error }); });
return () => { active = false; };
}, []);
if (state.error) throw state.error;
if (!state.data) return <Loading />;
return <div>{state.data}</div>;
}Оптимизация производительности
Виртуализация
Виртуализация рендерит только видимые строки длинного списка, значительно уменьшая количество DOM-узлов. Необходима для списков свыше 1000 элементов — без неё браузер захлёбывается на десятках тысяч узлов.
import { useVirtualizer } from '@tanstack/react-virtual';
function BigList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((vi) => (
<div key={vi.key} style={{ position: 'absolute', top: vi.start, height: vi.size }}>
{items[vi.index].name}
</div>
))}
</div>
</div>
);
}Разделение кода
Разделение кода разбивает bundle на chunk'и, загружаемые по требованию. Наиболее эффективные разделения — на уровне маршрутов (каждая страница — отдельный chunk) и тяжёлые виджеты (библиотеки графиков, редакторы). Измеряйте анализаторами bundle.
import { lazy, Suspense } from 'react';
const Admin = lazy(() => import('./Admin'));
const Chart = lazy(() => import('./Chart'));
function Page({ showChart }) {
return (
<Suspense fallback={<Skeleton />}>
{showChart && <Chart data={data} />}
</Suspense>
);
}Профилирование с DevTools
React DevTools Profiler записывает времена рендера и показывает, какие компоненты перерендерили и почему. Ищите «потраченные впустую» рендеры, где props фактически не изменились — это кандидаты для React.memo.
// Use React DevTools Profiler to record renders
// Look for:
// - Components rendering too often
// - Long commit phases
// - Wasted renders (props didn't change)
// Wrap expensive renders to find bottlenecks
function MyComponent({ data }) {
console.time('render');
const result = heavyCompute(data);
console.timeEnd('render');
return <div>{result}</div>;
}useDeferredValue
useDeferredValue откладывает обновление значения, позволяя срочным обновлениям (вводу) происходить первыми. Дорогой рендер использует отложенное значение, поэтому он не блокирует input. isStale позволяет показать тонкую визуальную подсказку.
function Search({ query }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const results = useMemo(() => expensiveSearch(deferredQuery), [deferredQuery]);
return <div style={{ opacity: isStale ? 0.7 : 1 }}>{results.map((r) => <div key={r.id}>{r.name}</div>)}</div>;
}Concurrent-функции
Concurrent-функции React 18 сохраняют UI отзывчивым при тяжёлой работе. useTransition и useDeferredValue позволяют React прерывать рендеры для обработки срочного ввода. Автоматическое пакетирование группирует несколько вызовов setState в один перерендер.
import { useTransition, useDeferredValue } from 'react';
// useTransition: mark updates as non-urgent
const [isPending, startTransition] = useTransition();
const filterResults = (q) => startTransition(() => setResults(search(q)));
// Automatic batching (React 18): state updates in promises batch automatically
fetch('/api').then(() => {
setLoading(false); // \
setData(data); // > single re-render
setError(null); // /
});Тестирование (React Testing Library)
Базовый рендер и запрос
render() монтирует компонент в фейковый DOM; screen запрашивает его. Предпочитайте запросы по role (getByRole) — они отражают, как вспомогательные технологии видят страницу, и обеспечивают доступность. userEvent симулирует реальные взаимодействия пользователя.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('increments on click', async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
const button = screen.getByRole('button', { name: /increment/i });
await user.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});Варианты запросов
getBy утверждает, что элемент существует (иначе выбрасывает исключение). queryBy — для утверждения отсутствия (возвращает null). findBy ожидает появления асинхронных элементов. Варианты All обрабатывают несколько совпадений.
// getBy: throws if 0 or >1 matches (strict)
screen.getByRole('button', { name: 'Submit' });
// queryBy: returns null if 0 matches (for assertions of absence)
expect(screen.queryByText('Error')).not.toBeInTheDocument();
// findBy: returns a Promise, waits for match (async)
const element = await screen.findByText('Loaded');
// getAllBy: returns array (multiple matches)
const items = screen.getAllByRole('listitem');Инициация событий
userEvent (не низкоуровневый fireEvent) — рекомендуемый способ симуляции взаимодействий — он запускает все события, что и реальный пользователь (focus, input, keydown, click), в правильном порядке. Всегда используйте await с методами userEvent.
import userEvent from '@testing-library/user-event';
test('form submission', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<Form onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(onSubmit).toHaveBeenCalled();
});waitFor и асинхронность
waitFor опрашивает, пока утверждение не пройдёт или не истечёт тайм-аут. findBy* сочетает waitFor и getBy для частого случая «дождаться появления этого». within ограничивает запросы конкретным элементом.
import { waitFor, within } from '@testing-library/react';
test('shows data after fetch', async () => {
render(<UserList />);
await waitFor(() => {
const list = screen.getByRole('list');
expect(within(list).getAllByRole('listitem')).toHaveLength(3);
});
});
// findBy is often cleaner than waitFor + getBy
test('shows data (cleaner)', async () => {
render(<UserList />);
expect(await screen.findAllByRole('listitem')).toHaveLength(3);
});Мокинг и настройка
MSW (Mock Service Worker) перехватывает сетевые запросы на уровне service-worker, поэтому ваш код fetch работает без изменений. Настраивайте обработчики для каждого теста, сбрасывайте между тестами и закрывайте после всех.
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/user', (req, res, ctx) => res(ctx.json({ name: 'Alice' })))
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('shows user name', async () => {
render(<UserProfile />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
});Связанные сниппеты React
Copy-paste ready code for common tasks.
Контролируемая форма с валидацией
Создание контролируемой React-формы с инлайн-валидацией и сообщениями об ошибках.
Обработка событий и рендеринг списков
Обработка событий и рендеринг динамических списков с ключами в React.
useState
Хук управления состоянием.
useEffect
Хук побочных эффектов.
useContext
Разделение состояния через контекст.
useReducer
Управление сложным состоянием.
useMemo
Мемоизация результатов вычислений.
useCallback
Мемоизация функций-колбэков.
useRef
Ссылка на DOM и изменяемые значения.
Пользовательские хуки
Извлечение переиспользуемой логики.
Компонентное взаимодейст вие
Взаимодействие родитель-потомок и между сиблингами.
Границы ошибок
Перехват ошибок компонентов.
Ленивая загрузка
Разделение кода и ленивая загрузка.
Portal
Рендеринг в DOM-узлы вне компонента.
Компоненты высшего порядка
Паттерн усиления компонентов.
Render Props
Паттерн render props.
Оптимизация производительности
Советы по оптимизации производительности React.
Was this helpful?