React_Data_Routing_Query

React Data: роутинг, запросы и формы

React Data — подходы и инструменты для навигации, загрузки серверных данных, общего состояния и форм в React-приложении.

Важно различать:

Для серверного состояния обычно подходит TanStack Query, а для простого общего клиентского состояния — Context API.

Установка

npm install react-router-dom @tanstack/react-query react-hook-form

React 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>
  );
}

На сервере для 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>

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,
});

Устаревшие данные могут оставаться на экране, пока библиотека обновляет их в фоне.

Мутации

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>
  );
}

Форма и мутация

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 уже зарегистрирован',
});

Клиентская валидация улучшает интерфейс, но не заменяет серверную проверку.


Состояния загрузки и ошибок

Следует различать:

  1. первичную загрузку;
  2. успешный ответ с данными;
  3. пустой результат;
  4. ошибку;
  5. фоновое обновление;
  6. выполняющуюся мутацию.
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} />
    </>
  );
}

Практические рекомендации:

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.jsx

Общий пример маршрутизации

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-ключи.


Краткие рекомендации