React_Data_Routing_Query
React Data: роутинг, запросы и формы
React Data — подходы и инструменты для навигации, загрузки серверных данных, общего состояния и форм в React-приложении.
Важно различать:
- серверное состояние — товары, пользователи и другие данные API;
- клиентское состояние — тема, локаль, состояние меню.
Для серверного состояния обычно подходит TanStack Query, а для простого общего клиентского состояния — Context API.
Установка
npm install react-router-dom @tanstack/react-query react-hook-formReact Router
React Router связывает URL с компонентами и позволяет переходить между страницами без полной перезагрузки.
Базовые маршруты
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import HomePage from './pages/HomePage.jsx';
import ProductsPage from './pages/ProductsPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/products" element={<ProductsPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
);
}BrowserRouterсинхронизирует интерфейс с адресной строкой.Routesвыбирает подходящий маршрут.Routeсвязывает путь с React-элементом.path="*"обрабатывает неизвестные адреса.
На сервере для SPA нужно настроить возврат index.html для клиентских маршрутов. Иначе прямое открытие /products/42 может дать серверный 404.
Ссылки
Для внутренних переходов используют Link:
import { Link, NavLink } from 'react-router-dom';
<Link to="/products">Товары</Link>
<NavLink
to="/products"
className={({ isActive }) => isActive ? 'link active' : 'link'}
>
Товары
</NavLink>Обычный <a> подходит для внешних ресурсов, но внутренний переход через него перезагружает страницу.
Параметры маршрута
<Route path="/products/:productId" element={<ProductPage />} />Получение параметра:
import { useParams } from 'react-router-dom';
function ProductPage() {
const { productId } = useParams();
return <h1>Товар №{productId}</h1>;
}Параметры URL являются строками. При необходимости значение проверяют и преобразуют:
const id = Number(productId);
if (!Number.isInteger(id) || id <= 0) {
return <p>Некорректный идентификатор</p>;
}Query-параметры
Для фильтров, поиска и пагинации подходит useSearchParams():
import { useSearchParams } from 'react-router-dom';
function Filters() {
const [params, setParams] = useSearchParams();
const category = params.get('category') ?? 'all';
const page = Number(params.get('page') ?? 1);
return (
<button onClick={() => setParams({ category: 'books', page: '1' })}>
Книги: страница {page}, текущая категория: {category}
</button>
);
}Состояние в URL можно сохранить в закладках и передать другому пользователю.
Вложенные маршруты
import { Outlet, Route, Routes } from 'react-router-dom';
function AppLayout() {
return (
<>
<Header />
<main><Outlet /></main>
</>
);
}
function AppRoutes() {
return (
<Routes>
<Route element={<AppLayout />}>
<Route index element={<HomePage />} />
<Route path="products" element={<ProductsPage />} />
<Route path="products/:productId" element={<ProductPage />} />
</Route>
</Routes>
);
}Outlet отмечает место вывода дочернего маршрута. index задаёт страницу по умолчанию для родительского пути.
Программная навигация
import { useNavigate } from 'react-router-dom';
function LoginButton() {
const navigate = useNavigate();
async function handleLogin() {
await login();
navigate('/profile', { replace: true });
}
return <button onClick={handleLogin}>Войти</button>;
}replace: true заменяет текущую запись истории.
Fetch в компонентах
Простой запрос можно выполнить в useEffect():
import { useEffect, useState } from 'react';
function UsersPage() {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function loadUsers() {
try {
setIsLoading(true);
setError(null);
const response = await fetch('/api/users', {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Ошибка HTTP: ${response.status}`);
}
setUsers(await response.json());
} catch (requestError) {
if (requestError.name !== 'AbortError') {
setError(requestError);
}
} finally {
if (!controller.signal.aborted) setIsLoading(false);
}
}
loadUsers();
return () => controller.abort();
}, []);
if (isLoading) return <p>Загрузка…</p>;
if (error) return <p role="alert">{error.message}</p>;
if (users.length === 0) return <p>Пользователи не найдены</p>;
return <ul>{users.map(user => <li key={user.id}>{user.name}</li>)}</ul>;
}fetch() отклоняет Promise при сетевой ошибке, но не при HTTP-ответах 404 и 500. Поэтому нужно проверять response.ok.
AbortController отменяет ненужный запрос при размонтировании компонента.
Общая функция запроса
export async function request(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
let message = `Ошибка HTTP: ${response.status}`;
try {
const body = await response.json();
message = body.message ?? message;
} catch {
// Ответ может не содержать JSON.
}
throw new Error(message);
}
if (response.status === 204) return null;
return response.json();
}API-функции:
export function getProducts({ signal } = {}) {
return request('/api/products', { signal });
}
export function createProduct(product) {
return request('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(product),
});
}При ручной загрузке самостоятельно приходится реализовывать кеширование, повторные запросы, синхронизацию компонентов и обновление данных после изменений. Эти задачи упрощает TanStack Query.
TanStack Query
TanStack Query управляет серверными данными: выполняет запросы, кеширует результаты, обновляет устаревшие значения и обрабатывает мутации.
QueryClientProvider
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>QueryClientхранит кеш и настройки.staleTimeзадаёт период свежести данных.retryзадаёт число повторов после ошибки.
useQuery
import { useQuery } from '@tanstack/react-query';
function ProductsPage() {
const query = useQuery({
queryKey: ['products'],
queryFn: ({ signal }) => getProducts({ signal }),
});
if (query.isPending) return <p>Загрузка товаров…</p>;
if (query.isError) return <p role="alert">{query.error.message}</p>;
if (query.data.length === 0) return <p>Список пуст</p>;
return (
<>
{query.isFetching && <p>Обновление…</p>}
<ul>
{query.data.map(product => (
<li key={product.id}>{product.title}</li>
))}
</ul>
</>
);
}isPending означает, что первичных данных ещё нет. isFetching показывает любой активный запрос, включая фоновое обновление.
queryKey и параметры
Все параметры, влияющие на результат, включают в ключ:
const query = useQuery({
queryKey: ['products', { category, page }],
queryFn: ({ signal }) => getProducts({ category, page, signal }),
});Для отдельной сущности:
const query = useQuery({
queryKey: ['product', productId],
queryFn: ({ signal }) => getProduct(productId, { signal }),
enabled: Boolean(productId),
});['product', 10] и ['product', '10'] — разные ключи. Тип идентификатора должен быть согласован во всём приложении.
Кеш
useQuery({
queryKey: ['products'],
queryFn: getProducts,
staleTime: 60_000,
gcTime: 5 * 60_000,
});staleTime— период свежести данных;gcTime— срок хранения неиспользуемого кеша.
Устаревшие данные могут оставаться на экране, пока библиотека обновляет их в фоне.
Мутации
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateButton() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createProduct,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
return (
<>
<button
onClick={() => mutation.mutate({ title: 'Книга', price: 1000 })}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Создание…' : 'Создать'}
</button>
{mutation.isError && <p role="alert">{mutation.error.message}</p>}
</>
);
}invalidateQueries() помечает связанные данные устаревшими и инициирует их актуализацию.
Если нужен Promise, используют mutateAsync():
try {
const product = await mutation.mutateAsync(values);
navigate(`/products/${product.id}`);
} catch (error) {
console.error(error);
}Обновление кеша
Если сервер вернул актуальную сущность, её можно записать напрямую:
queryClient.setQueryData(
['product', String(updatedProduct.id)],
updatedProduct,
);
queryClient.invalidateQueries({ queryKey: ['products'] });Оптимистическое обновление
const mutation = useMutation({
mutationFn: deleteProduct,
onMutate: async (productId) => {
await queryClient.cancelQueries({ queryKey: ['products'] });
const previous = queryClient.getQueryData(['products']);
queryClient.setQueryData(['products'], (current = []) =>
current.filter(product => product.id !== productId),
);
return { previous };
},
onError: (_error, _id, context) => {
queryClient.setQueryData(['products'], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});Оптимистическое обновление ускоряет интерфейс, но требует корректного отката при ошибке.
Context API
Context передаёт данные через дерево компонентов без промежуточной передачи props.
import { createContext, useContext, useMemo, useState } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({
theme,
toggleTheme() {
setTheme(current => current === 'light' ? 'dark' : 'light');
},
}), [theme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === null) {
throw new Error('useTheme должен использоваться внутри ThemeProvider');
}
return context;
}Подключение и использование:
<ThemeProvider>
<App />
</ThemeProvider>function ThemeButton() {
const { theme, toggleTheme } = useTheme();
return <button onClick={toggleTheme}>Тема: {theme}</button>;
}Context подходит для темы, локали, текущего пользователя и небольшого общего состояния интерфейса. Серверные данные не стоит дублировать из TanStack Query в Context.
При изменении value потребители контекста перерисовываются. Большие контексты лучше разделять по назначению и размещать ближе к потребителям.
React Hook Form
React Hook Form регистрирует поля, выполняет валидацию и управляет отправкой формы.
import { useForm } from 'react-hook-form';
function ProductForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
defaultValues: { title: '', price: '' },
});
async function onSubmit(values) {
console.log(values);
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="title">Название</label>
<input
id="title"
{...register('title', {
required: 'Введите название',
minLength: { value: 3, message: 'Минимум 3 символа' },
})}
aria-invalid={Boolean(errors.title)}
/>
{errors.title && <p role="alert">{errors.title.message}</p>}
<label htmlFor="price">Цена</label>
<input
id="price"
type="number"
{...register('price', {
required: 'Введите цену',
valueAsNumber: true,
min: { value: 0, message: 'Цена не может быть отрицательной' },
})}
/>
{errors.price && <p role="alert">{errors.price.message}</p>}
<button disabled={isSubmitting}>
{isSubmitting ? 'Сохранение…' : 'Сохранить'}
</button>
</form>
);
}register()связывает поле с формой;handleSubmit()запускает валидацию;errorsсодержит ошибки;defaultValuesзадаёт начальные значения;valueAsNumberпреобразует строку в число.
Форма и мутация
const mutation = useMutation({
mutationFn: createProduct,
onSuccess: async product => {
await queryClient.invalidateQueries({ queryKey: ['products'] });
navigate(`/products/${product.id}`);
},
});
async function onSubmit(values) {
try {
await mutation.mutateAsync(values);
} catch (error) {
setError('root.server', {
type: 'server',
message: error.message,
});
}
}Вывод общей ошибки:
{errors.root?.server && (
<p role="alert">{errors.root.server.message}</p>
)}Серверную ошибку конкретного поля можно установить так:
setError('email', {
type: 'server',
message: 'Такой email уже зарегистрирован',
});Клиентская валидация улучшает интерфейс, но не заменяет серверную проверку.
Состояния загрузки и ошибок
Следует различать:
- первичную загрузку;
- успешный ответ с данными;
- пустой результат;
- ошибку;
- фоновое обновление;
- выполняющуюся мутацию.
function ProductsContent({ query }) {
if (query.isPending) {
return <p aria-live="polite">Загрузка…</p>;
}
if (query.isError) {
return (
<section role="alert">
<p>Не удалось загрузить товары.</p>
<button onClick={() => query.refetch()}>Повторить</button>
</section>
);
}
if (query.data.length === 0) {
return <p>Ничего не найдено.</p>;
}
return (
<>
{query.isFetching && <p aria-live="polite">Обновление…</p>}
<ProductList products={query.data} />
</>
);
}Практические рекомендации:
- сохраняйте уже загруженные данные во время фонового обновления;
- блокируйте только выполняемое действие;
- предотвращайте повторную отправку формы;
- давайте возможность повторить запрос;
- отдельно отображайте пустой результат;
- используйте
role="alert"иaria-live; - не показывайте внутренний стек ошибки и секретные данные.
Error Boundary перехватывает ошибки рендеринга компонентов, но не заменяет обработку ошибок запросов и обработчиков событий.
Структура проекта
src/
├── api/
│ ├── request.js
│ └── products.js
├── app/
│ ├── App.jsx
│ └── AppProviders.jsx
├── components/
│ ├── ProductForm.jsx
│ └── ProductList.jsx
├── contexts/
│ └── ThemeContext.jsx
├── layouts/
│ └── AppLayout.jsx
├── pages/
│ ├── ProductsPage.jsx
│ ├── ProductPage.jsx
│ └── CreateProductPage.jsx
└── main.jsxapi— HTTP-функции;pages— компоненты маршрутов;components— переиспользуемый интерфейс;contexts— общее клиентское состояние;app— провайдеры и маршрутизация;layouts— общая структура страниц.
Общий пример маршрутизации
import { Route, Routes } from 'react-router-dom';
export default function App() {
return (
<Routes>
<Route element={<AppLayout />}>
<Route index element={<HomePage />} />
<Route path="products" element={<ProductsPage />} />
<Route path="products/new" element={<CreateProductPage />} />
<Route path="products/:productId" element={<ProductPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
);
}Провайдеры приложения:
const queryClient = new QueryClient();
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<App />
</ThemeProvider>
</QueryClientProvider>
</BrowserRouter>
</StrictMode>,
);Частые ошибки
Параметр не включён в queryKey
// Плохо: все товары используют один кеш.
queryKey: ['product']
// Хорошо.
queryKey: ['product', productId]Дублирование серверных данных
Не копируйте результат useQuery() в локальный state или Context без необходимости. Две копии могут рассинхронизироваться.
const available = data?.filter(product => product.inStock) ?? [];Производное значение лучше вычислять из данных запроса.
Отсутствие проверки response.ok
const response = await fetch('/api/products');
if (!response.ok) {
throw new Error(`Ошибка HTTP: ${response.status}`);
}Секреты в клиентском приложении
Переменные, встроенные в клиентскую сборку, доступны пользователю. Нельзя хранить в React-приложении пароли и приватные API-ключи.
Краткие рекомендации
- Используйте React Router для URL и навигации.
- Храните фильтры и пагинацию в query-параметрах.
- Используйте ручной
fetchдля простых локальных запросов. - Применяйте TanStack Query для кеширования, повторного использования данных и мутаций.
- Включайте параметры запроса в
queryKey. - После мутации обновляйте кеш или инвалидируйте запросы.
- Не дублируйте данные Query в Context.
- Используйте Context для ограниченного клиентского состояния.
- Применяйте React Hook Form для форм и валидации.
- Раздельно обрабатывайте загрузку, ошибку, пустой результат и фоновое обновление.