React Lab Survival: FSD, Auth и React Query

Практический курс для подготовки к лабораторной работе, охватывающий архитектуру FSD, сложную JWT-авторизацию и управление данными. Вы научитесь строить масштабируемые приложения с валидацией форм и оптимизированными запросами к API.

Архитектура Feature-Sliced Design и настройка HTTP-клиента

Архитектура Feature-Sliced Design и настройка HTTP-клиента

Добро пожаловать в курс React Lab Survival. Если вы читаете это, значит, перед вами стоит задача сдать лабораторную работу, которая звучит как список требований к коммерческому проекту: авторизация с двумя токенами, сложная валидация, React Query и архитектура FSD. Звучит страшно, но мы разберем этого слона по частям.

Эта статья — фундамент. Мы не будем писать код «как попало», чтобы потом переписывать его в ночь перед дедлайном. Мы сразу настроим проект так, чтобы преподавателю было не к чему придраться в плане структуры.

Почему Feature-Sliced Design?

В маленьких проектах (например, To-Do List) мы привыкли делить код по типам файлов: папка components, папка hooks, папка pages. Это называется File-Type Architecture. Но когда проект растет, компоненты начинают путаться, импорты превращаются в паутину, и понять, какая кнопка к какой логике относится, становится невозможно.

Feature-Sliced Design (FSD) — это архитектурная методология, которая делит код не по типам файлов, а по бизнес-ценности. Она заставляет вас отвечать на вопрос: «Для чего нужен этот код?», а не «Что это за файл?».

Слои FSD

FSD делит приложение на слои. Это строгая иерархия: нижние слои не могут использовать верхние. Это спасает от циклических зависимостей.

Разберем слои снизу вверх (от самых независимых к самым сложным):

  1. Shared (Общее) Самый нижний слой. Здесь лежит код, который не знает ничего о бизнесе вашего приложения. Это UI-кит (кнопки, инпуты), утилиты, конфигурация API, типы данных. Пример: Button, Input, axiosInstance.

  2. Entities (Сущности) Бизнес-единицы, с которыми работает ваше приложение. Они содержат данные и отображение этих данных, но не содержат логику взаимодействия. Пример: User (карточка профиля), Product (карточка товара).

  3. Features (Фичи) Слой действий. Здесь происходит взаимодействие пользователя с сущностями. Это то, что приносит ценность. Пример: AuthByEmail (форма входа), AddToCart (кнопка добавления в корзину).

  4. Widgets (Виджеты) Самостоятельные блоки страницы, объединяющие сущности и фичи. Пример: Header (логотип + меню + профиль), ProductList (список товаров с фильтрами).

  5. Pages (Страницы) Композиция виджетов, фич и сущностей для конкретного маршрута. Пример: LoginPage, CatalogPage.

  6. App (Приложение) Инициализация. Здесь подключаются провайдеры, роутер, глобальные стили. Пример: App.tsx, providers, styles.

Структура проекта для лабораторной

Исходя из требований вашей лабы (Auth + CRUD), структура папок в src будет выглядеть примерно так:

src/
├── app/                # Провайдеры, роутер, глобальные стили
├── pages/              # Страницы (Login, Register, Dashboard)
├── widgets/            # Header, Sidebar
├── features/           # Auth (формы входа/регистрации), CreateItem, DeleteItem
├── entities/           # Session (данные юзера), Item (сущность для CRUD)
└── shared/             # UI-kit, API config, hooks, constants

Главное правило FSD: модуль может импортировать только из модулей, находящихся на слоях ниже него.

Настройка HTTP-клиента (Axios)

Для работы с сетью мы будем использовать библиотеку Axios. Она является стандартом де-факто благодаря встроенной поддержке перехватчиков (interceptors), которые критически важны для реализации авторизации с обновлением токенов.

Установка

npm install axios

Создание инстанса

В FSD конфигурация API относится к слою Shared. Создадим файл src/shared/api/api.ts.

Мы не будем использовать глобальный объект axios, а создадим настроенный экземпляр (instance). Это позволит нам задать базовый URL и настройки один раз.

// src/shared/api/api.ts
import axios from 'axios';

// Базовый URL берем из переменных окружения
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';

export const api = axios.create({
  baseURL: BASE_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});

Зачем нужны Interceptors (Перехватчики)?

В требованиях к лабе указано: «Авторизация с двумя токенами и автообновлением access». Это значит, что:

  1. К каждому запросу нужно автоматически добавлять Access Token.
  2. Если сервер ответил ошибкой 401 (Unauthorized), нужно попробовать обновить токены и повторить запрос.

Делать это вручную в каждом компоненте — безумие. Для этого и нужны перехватчики.

Request Interceptor (Перехватчик запроса)

Он срабатывает перед тем, как запрос улетит на сервер. Здесь мы добавляем токен.

// Добавляем в src/shared/api/api.ts

// Ключ, под которым токен лежит в localStorage
const ACCESS_TOKEN_KEY = 'access_token';

api.interceptors.request.use((config) => {
  const token = localStorage.getItem(ACCESS_TOKEN_KEY);

  if (token && config.headers) {
    config.headers.Authorization = `Bearer ${token}`;
  }

  return config;
});
Response Interceptor (Перехватчик ответа)

Он срабатывает, когда ответ пришел от сервера. Здесь мы ловим ошибки. Полную логику обновления токенов (Refresh Flow) мы реализуем в следующей статье, посвященной авторизации, но каркас подготовим сейчас.

// Добавляем в src/shared/api/api.ts

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // Если ошибка 401 и мы еще не пытались обновить токен
    if (error.response?.status === 401 && !error.config._isRetry) {
      originalRequest._isRetry = true;

      try {
        // Здесь будет логика обновления токенов
        // const response = await axios.post(...)
        // localStorage.setItem(ACCESS_TOKEN_KEY, response.data.accessToken);

        // Повторяем оригинальный запрос с новым токеном
        return api.request(originalRequest);
      } catch (e) {
        // Если не вышло обновить токен — разлогиниваем юзера
        console.error('Не авторизован');
      }
    }

    throw error;
  }
);

Интеграция с переменными окружения

Никогда не хардкодьте адреса API. Создайте в корне проекта файл .env:

VITE_API_URL=http://localhost:5000/api

И добавьте .env в .gitignore, чтобы не слить секретные ключи (если они будут) в репозиторий.

Резюме

Мы заложили прочный фундамент:

  1. FSD спасет нас от превращения кода в спагетти по мере роста функционала.
  2. Слои четко определяют, где должна лежать логика, а где — UI.
  3. Axios Instance в слое Shared готов к работе и автоматически подставляет токены.

В следующей статье мы реализуем полноценную систему авторизации, подключим React Query и заставим этот механизм работать как часы.

Реализация JWT авторизации: Access/Refresh токены и автоматическое обновление

Реализация JWT авторизации: Access/Refresh токены и автоматическое обновление

Приветствую, выжившие. В прошлой статье мы заложили фундамент архитектуры FSD и настроили базовый экземпляр Axios. Сегодня мы займемся самой «страшной» частью лабораторной работы — авторизацией с двумя токенами.

Почему это страшно? Потому что это асинхронный процесс, который происходит скрыто от пользователя. Если вы сделаете это неправильно, ваше приложение уйдет в бесконечный цикл запросов, и браузер зависнет. Но если сделаете правильно — преподаватель будет в восторге.

Теория: Зачем нам два токена?

В простых приложениях часто используют один токен, который живет вечно. Это небезопасно: если злоумышленник украдет токен, он получит доступ к аккаунту навсегда.

Стандарт JWT (JSON Web Token) в безопасной реализации подразумевает использование пары токенов:

  1. Access Token (Токен доступа)

    • Живет: Мало (15–30 минут).
    • Использование: Отправляется в заголовке Authorization каждого запроса.
    • Хранение: В памяти JS или localStorage (для лабы допустимо).
  2. Refresh Token (Токен обновления)

    • Живет: Долго (2 недели – месяц).
    • Использование: Отправляется только на один эндпоинт: /auth/refresh.
    • Цель: Получить новую пару Access/Refresh токенов, когда старый Access протух.

Хранение токенов в FSD

Согласно Feature-Sliced Design, работа с токенами — это низкоуровневая логика, которая может понадобиться везде. Однако, сами данные о сессии (залогинен ли юзер) относятся к слою Entities (сущность Session или User). А вот функции для работы с localStorage лучше вынести в Shared.

Создадим файл утилит для токенов:

// src/shared/lib/auth/token-storage.ts

const ACCESS_TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refresh_token';

export const getAccessToken = () => localStorage.getItem(ACCESS_TOKEN_KEY);
export const getRefreshToken = () => localStorage.getItem(REFRESH_TOKEN_KEY);

export const setTokens = (access: string, refresh: string) => {
  localStorage.setItem(ACCESS_TOKEN_KEY, access);
  localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
};

export const clearTokens = () => {
  localStorage.removeItem(ACCESS_TOKEN_KEY);
  localStorage.removeItem(REFRESH_TOKEN_KEY);
};

Реализация Interceptor (Перехватчика)

Вернемся к нашему файлу src/shared/api/api.ts. В прошлой статье мы написали заготовку. Теперь наполним её реальной логикой.

Проблема параллельных запросов

Представьте ситуацию: ваше приложение загружается и делает одновременно 5 запросов (профиль, список товаров, корзина, новости, настройки). У всех запросов протухший токен.

Если не предусмотреть защиту, произойдет следующее:

  1. Все 5 запросов получат 401.
  2. Все 5 перехватчиков попытаются отправить запрос /refresh.
  3. Сервер обработает первый, выдаст новые токены и сделает старый Refresh токен невалидным (обычная практика безопасности — Refresh Token Rotation).
  4. Остальные 4 запроса на обновление упадут с ошибкой, и пользователя выкинет из системы.

Решение: Нам нужен механизм очереди (или мьютекса). Если обновление уже идет, остальные запросы должны ждать его завершения.

Полный код api.ts

// src/shared/api/api.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import {
  getAccessToken,
  getRefreshToken,
  setTokens,
  clearTokens
} from '../lib/auth/token-storage';

const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';

export const api = axios.create({
  baseURL: BASE_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});

// Флаг, указывающий, происходит ли сейчас обновление токена
let isRefreshing = false;

// Очередь запросов, которые пришли во время обновления токена
let failedQueue: any[] = [];

const processQueue = (error: any, token: string | null = null) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });

  failedQueue = [];
};

// 1. Request Interceptor: Добавляем токен
api.interceptors.request.use((config) => {
  const token = getAccessToken();
  if (token && config.headers) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// 2. Response Interceptor: Обрабатываем ошибки
api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as InternalAxiosRequestConfig & { _isRetry?: boolean };

    // Если ошибка не 401 или запрос уже был повторен — выбрасываем ошибку дальше
    if (error.response?.status !== 401 || originalRequest._isRetry) {
      return Promise.reject(error);
    }

    // Если обновление уже идет, добавляем запрос в очередь
    if (isRefreshing) {
      return new Promise(function(resolve, reject) {
        failedQueue.push({ resolve, reject });
      })
      .then((token) => {
        if (originalRequest.headers) {
          originalRequest.headers.Authorization = `Bearer ${token}`;
        }
        return api(originalRequest);
      })
      .catch((err) => {
        return Promise.reject(err);
      });
    }

    originalRequest._isRetry = true;
    isRefreshing = true;

    try {
      const refreshToken = getRefreshToken();

      if (!refreshToken) {
        throw new Error('Нет refresh токена');
      }

      // ВАЖНО: Делаем запрос через чистый axios, чтобы не зациклить интерцепторы
      // Или используем отдельный инстанс для auth
      const { data } = await axios.post(`${BASE_URL}/auth/refresh`, {
        refreshToken
      });

      const { accessToken, refreshToken: newRefreshToken } = data;

      setTokens(accessToken, newRefreshToken);

      // Обрабатываем очередь накопившихся запросов
      processQueue(null, accessToken);

      // Повторяем текущий запрос
      if (originalRequest.headers) {
        originalRequest.headers.Authorization = `Bearer ${accessToken}`;
      }

      return api(originalRequest);
    } catch (refreshError) {
      // Если обновить не удалось — значит сессия умерла окончательно
      processQueue(refreshError, null);
      clearTokens();
      // Здесь можно сделать редирект на логин
      // window.location.href = '/login';
      return Promise.reject(refreshError);
    } finally {
      isRefreshing = false;
    }
  }
);

Разбор ключевых моментов

Очередь failedQueue

Это массив объектов, содержащих resolve и reject от промисов. Когда приходит 401, но isRefreshing === true, мы не делаем запрос, а создаем новый Promise и кладем его методы управления в массив. Когда обновление завершится, мы вызовем processQueue, который разрезолвит (выполнит) все ожидающие промисы с новым токеном.

Флаг _isRetry

Мы расширяем стандартный конфиг Axios свойством _isRetry. Это защита от бесконечного цикла. Если мы обновили токен, повторили запрос, и он снова вернул 401 (например, сервер глючит или токен сразу протух), мы не должны пытаться обновлять его вечно. Мы просто выкидываем ошибку.

Интеграция с React Query

Самое прекрасное в этом подходе то, что React Query даже не узнает о том, что произошла ошибка 401.

Для React Query это выглядит так:

  1. Он вызвал функцию-фетчер (которая использует наш api).
  2. Функция «подумала» чуть дольше обычного (пока шло обновление токенов).
  3. Функция вернула успешные данные.

Вам не нужно писать никакой дополнительной логики в useQuery или useMutation. Вся грязь спрятана внутри Axios.

Обработка выхода (Logout)

В блоке catch внутри интерцептора есть комментарий про редирект. В FSD слое Shared мы не имеем доступа к роутеру приложения (так как роутер находится выше, в слое App или используется в Pages).

Как быть? Есть два пути:

  1. Простой (для лабы): window.location.href = '/login'. Это жесткая перезагрузка страницы, но это работает надежно.
  2. Архитектурный: Использовать паттерн "Event Bus" или подписаться на изменения стора вне React-компонентов. Но для лабораторной работы первого варианта более чем достаточно.

Резюме

Мы реализовали надежный механизм авторизации:

  • Безопасность: Access токены живут недолго.
  • UX: Пользователь не замечает обновления токенов.
  • Стабильность: Очередь запросов защищает от гонки (Race Condition).
  • Архитектура: Логика инкапсулирована в shared/api и не засоряет компоненты.

Теперь, когда наш HTTP-клиент стал «умным», мы готовы переходить к созданию форм и валидации данных, чем и займемся в следующей статье.

Валидация форм: связка React Hook Form и Zod

Валидация форм: связка React Hook Form и Zod

Добро пожаловать обратно в лабораторию. В прошлых статьях мы построили надежный HTTP-клиент и разобрались с токенами. Теперь у нас есть «труба» для данных, но что именно мы будем в неё отправлять?

Если вы когда-либо писали формы на чистом React, вы знаете эту боль: бесконечные useState, ручные проверки if (email.includes('@')), управление состоянием isSubmitting и errors. В коммерческой разработке (и в вашей лабораторной) так делать нельзя. Код превратится в спагетти быстрее, чем вы скажете «дедлайн».

Сегодня мы освоим «Священный Грааль» React-форм: React Hook Form (RHF) для управления состоянием и Zod для валидации данных.

Почему именно эта связка?

React Hook Form

Это библиотека для работы с формами, которая ставит во главу угла производительность. В отличие от контролируемых компонентов (где каждое нажатие клавиши вызывает ререндер всей формы), RHF использует неконтролируемые компоненты и прямую работу с DOM через ссылки (refs). Это значит, что ваша форма будет летать даже на слабых устройствах.

Zod

Это библиотека для описания схем и валидации TypeScript-first. Она позволяет описать правила данных один раз и автоматически получить:

  1. Логику валидации.
  2. Типы TypeScript (через z.infer).

Вам больше не нужно вручную писать интерфейсы для форм. Типы выводятся из правил валидации. Это исключает ситуацию, когда валидация проверяет одно, а TypeScript ожидает другое.

Установка зависимостей

Нам понадобятся сама библиотека форм, Zod и специальный «переходник» (resolver), который позволяет им дружить.

npm install react-hook-form zod @hookform/resolvers

Архитектура FSD: Куда класть формы?

В Feature-Sliced Design важно разделять ответственность:

  1. Shared: Здесь лежат переиспользуемые UI-компоненты (Input, Button) и общие правила валидации (например, регулярка для телефона).
  2. Features: Здесь лежат конкретные формы (LoginForm, RegisterForm) и их специфичные схемы валидации.

Шаг 1: Создание умного Input (Shared)

Чтобы RHF мог управлять инпутом, ему нужен доступ к ref этого элемента. В React для передачи ref в кастомный компонент используется forwardRef.

Создадим src/shared/ui/Input/Input.tsx:

import { InputHTMLAttributes, forwardRef } from 'react';

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  label?: string;
  error?: string;
}

// forwardRef обязателен для работы с React Hook Form
export const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, className, ...props }, ref) => {
    return (
      <div className={`flex flex-col gap-1 ${className}`}>
        {label && (
          <label className="text-sm font-medium text-gray-700">
            {label}
          </label>
        )}
        <input
          ref={ref}
          className={`border rounded p-2 outline-none transition-colors ${
            error ? 'border-red-500' : 'border-gray-300 focus:border-blue-500'
          }`}
          {...props}
        />
        {error && (
          <span className="text-xs text-red-500">{error}</span>
        )}
      </div>
    );
  }
);

Input.displayName = 'Input';

Шаг 2: Описание схемы валидации (Feature)

Допустим, мы делаем форму входа. Это фича Auth. Создадим схему в src/features/auth/model/login-schema.ts.

import { z } from 'zod';

export const loginSchema = z.object({
  email: z.string()
    .min(1, { message: 'Email обязателен' })
    .email({ message: 'Некорректный формат email' }),
  password: z.string()
    .min(6, { message: 'Пароль должен быть не менее 6 символов' })
    .max(32, { message: 'Пароль слишком длинный' }),
});

// Магия TypeScript: автоматически выводим тип из схемы
export type LoginSchema = z.infer<typeof loginSchema>;

Обратите внимание на z.infer. Нам не нужно писать interface LoginSchema { email: string... }. Zod делает это за нас. Если вы измените .email() на .number(), TypeScript сразу подсветит ошибки во всем проекте.

Шаг 3: Сборка формы (Feature)

Теперь соберем всё вместе в компоненте LoginForm. Мы будем использовать хук useForm и zodResolver.

Файл: src/features/auth/ui/LoginForm/LoginForm.tsx

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema, LoginSchema } from '../../model/login-schema';
import { Input } from '../../../../shared/ui/Input/Input';

export const LoginForm = () => {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<LoginSchema>({
    resolver: zodResolver(loginSchema),
    mode: 'onBlur', // Валидация сработает, когда пользователь уберет фокус с поля
  });

  const onSubmit = async (data: LoginSchema) => {
    // Эта функция вызовется ТОЛЬКО если валидация прошла успешно
    console.log('Отправка данных:', data);

    // Здесь будет вызов API, который мы писали в прошлой статье
    // await authService.login(data);
  };

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      className="flex flex-col gap-4 max-w-md mx-auto p-4 border rounded shadow"
    >
      <h2 className="text-xl font-bold">Вход в систему</h2>

      <Input
        label="Email"
        type="email"
        placeholder="user@example.com"
        error={errors.email?.message}
        // register связывает инпут с хуком формы
        {...register('email')}
      />

      <Input
        label="Пароль"
        type="password"
        placeholder="******"
        error={errors.password?.message}
        {...register('password')}
      />

      <button
        type="submit"
        disabled={isSubmitting}
        className="bg-blue-600 text-white p-2 rounded hover:bg-blue-700 disabled:opacity-50"
      >
        {isSubmitting ? 'Вход...' : 'Войти'}
      </button>
    </form>
  );
};

Разбор ключевых моментов

Функция register

Функция register('fieldName') возвращает набор пропсов: onChange, onBlur, ref и name. Распыляя их через {...register('email')}, мы внедряем логику RHF внутрь нашего компонента Input. Именно поэтому нам нужен был forwardRef в компоненте Input — RHF должен иметь доступ к нативному DOM-элементу.

Обработка ошибок

Объект errors автоматически заполняется сообщениями из нашей Zod-схемы. Если пользователь введет "invalid-email", в errors.email.message появится текст "Некорректный формат email". Нам не нужно писать никакой логики отображения — просто передать строку в проп error.

handleSubmit

Это обертка высокого порядка. Она:

  1. Предотвращает стандартное поведение формы (e.preventDefault()).
  2. Запускает валидацию всех полей.
  3. Если есть ошибки — прерывает выполнение и обновляет объект errors.
  4. Если ошибок нет — вызывает вашу функцию onSubmit и передает ей уже типизированные и проверенные данные.

Продвинутая валидация

Zod позволяет делать очень сложные проверки. Например, проверка совпадения паролей при регистрации:

export const registerSchema = z.object({
  password: z.string().min(6),
  confirmPassword: z.string().min(6),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Пароли не совпадают",
  path: ["confirmPassword"], // Ошибка привяжется к полю confirmPassword
});

Резюме

Мы внедрили мощный инструмент, который сэкономит вам часы отладки.

  1. Zod гарантирует, что данные соответствуют ожиданиям, и дает бесплатные типы.
  2. React Hook Form управляет состоянием формы без лишних ререндеров.
  3. FSD помогает разложить всё по полочкам: UI отдельно, логика отдельно.

Теперь у нас есть всё для создания полноценного приложения: архитектура, сеть, авторизация и формы. В следующей статье мы объединим это всё, добавив React Query для реализации CRUD-операций с умным кэшированием и состоянием загрузки.

Магия React Query: реализация CRUD операций и кэширование

Магия React Query: реализация CRUD операций и кэширование

Добро пожаловать на финишную прямую подготовки к лабораторной работе. У нас уже есть настроенный HTTP-клиент с авторизацией (Axios + Interceptors) и мощные формы (React Hook Form + Zod). Остался последний, но самый важный элемент — данные.

В старые времена (года 3 назад) мы писали useEffect, вручную создавали состояния isLoading, isError, data, боролись с гонкой запросов и лишними ререндерами. Сегодня мы будем использовать TanStack Query (React Query). Это библиотека, которая превращает работу с серверным состоянием в магию.

Почему React Query?

React Query решает три главные проблемы, с которыми вы столкнетесь в лабе:

  1. Кэширование: Если вы загрузили список товаров, перешли на другую страницу и вернулись — данные покажутся мгновенно из кэша, а фоновый запрос обновит их, если нужно.
  2. Дедупликация: Если два компонента одновременно просят одни и те же данные, уйдет только один запрос на сервер.
  3. Синхронизация: После создания или удаления элемента список обновляется автоматически. Больше никаких items.filter(...) вручную.

Настройка провайдера

В архитектуре FSD глобальные провайдеры живут в слое App. Нам нужно обернуть все приложение в QueryClientProvider.

Создадим файл настройки клиента. Обычно это делают в shared/api/query-client.ts или прямо в app/providers.

// src/shared/api/query-client.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Данные считаются свежими 1 минуту
      staleTime: 60 * 1000,
      // Не делать повторный запрос при ошибке (для лабы удобнее false)
      retry: false,
      refetchOnWindowFocus: false,
    },
  },
});

Теперь подключим его в App.tsx:

// src/app/App.tsx
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '@/shared/api/query-client';
import { Router } from './router';

export const App = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <Router />
    </QueryClientProvider>
  );
};

Чтение данных (Read) с useQuery

Допустим, у нас есть сущность Product. В FSD запросы к API для сущностей лежат в entities/product/api.

Для получения данных используется хук useQuery. Ему нужны две вещи:

  1. Query Key (Ключ запроса): Уникальный массив, по которому кэшируются данные. Например: ['products'] или ['products', id].
  2. Query Fn (Функция запроса): Асинхронная функция, возвращающая данные. Здесь мы используем наш настроенный api из прошлой статьи.
// src/entities/product/api/product-queries.ts
import { useQuery } from '@tanstack/react-query';
import { api } from '@/shared/api/api';

// Типизация ответа (лучше вынести в model/types.ts)
interface Product {
  id: string;
  name: string;
  price: number;
}

// Ключи выносим в объект, чтобы не опечататься
export const productKeys = {
  all: ['products'] as const,
  detail: (id: string) => ['products', id] as const,
};

export const useProducts = () => {
  return useQuery({
    queryKey: productKeys.all,
    queryFn: async () => {
      const response = await api.get<Product[]>('/products');
      return response.data;
    },
  });
};

Теперь в компоненте (например, widgets/ProductList) использование выглядит элементарно:

const { data, isLoading, isError } = useProducts();

if (isLoading) return <Loader />;
if (isError) return <div>Ошибка загрузки</div>;

return (
  <ul>
    {data?.map(product => <li key={product.id}>{product.name}</li>)}
  </ul>
);

Изменение данных (CUD) с useMutation

Для создания, обновления и удаления используется хук useMutation. В отличие от useQuery, он не запускается автоматически при рендере. Он возвращает функцию mutate, которую вы вызываете сами (например, по клику).

Самое важное здесь — инвалидация кэша. Когда мы создаем новый продукт, наш кэш списка продуктов (['products']) становится устаревшим. Мы должны сказать React Query: «Эй, данные изменились, перезапроси их».

Реализуем хук создания продукта:

// src/entities/product/api/product-mutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/shared/api/api';
import { productKeys } from './product-queries';

export const useCreateProduct = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (newProduct: { name: string; price: number }) => {
      return api.post('/products', newProduct);
    },
    // Магия происходит здесь
    onSuccess: () => {
      // Мы говорим: все запросы с ключом productKeys.all невалидны
      // React Query тут же сделает фоновый запрос и обновит список
      queryClient.invalidateQueries({ queryKey: productKeys.all });
    },
  });
};

Использование в форме

Объединим это с формой из прошлой статьи:

// src/features/create-product/ui/CreateProductForm.tsx
import { useCreateProduct } from '@/entities/product/api/product-mutations';
// ... импорты формы

export const CreateProductForm = () => {
  const { mutate, isPending } = useCreateProduct();
  // isPending заменяет нам ручной loading state

  const onSubmit = (data: CreateProductSchema) => {
    mutate(data, {
      onSuccess: () => {
        // Можно закрыть модалку или очистить форму
        reset();
      },
      onError: (error) => {
        // Обработка ошибок
        console.error(error);
      }
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Поля формы */}
      <button disabled={isPending}>
        {isPending ? 'Создание...' : 'Создать'}
      </button>
    </form>
  );
};

Оптимистичные обновления (Бонус для отличников)

Иногда мы не хотим ждать ответа сервера, чтобы обновить UI. Например, при лайке поста. Мы хотим, чтобы сердечко загорелось мгновенно.

React Query позволяет менять кэш вручную до того, как придет ответ сервера. Если сервер вернет ошибку, изменения откатятся.

useMutation({
  mutationFn: updateProduct,
  onMutate: async (newProduct) => {
    // Отменяем исходящие запросы, чтобы они не перезаписали наше оптимистичное состояние
    await queryClient.cancelQueries({ queryKey: productKeys.all });

    // Сохраняем предыдущее состояние
    const previousProducts = queryClient.getQueryData(productKeys.all);

    // Обновляем кэш вручную
    queryClient.setQueryData(productKeys.all, (old: Product[]) => [
      ...old,
      { ...newProduct, id: 'temp-id' }
    ]);

    return { previousProducts };
  },
  onError: (err, newTodo, context) => {
    // Если ошибка — откатываемся назад
    queryClient.setQueryData(productKeys.all, context.previousProducts);
  },
  onSettled: () => {
    // В любом случае обновляем данные с сервера для надежности
    queryClient.invalidateQueries({ queryKey: productKeys.all });
  },
});

Для лабораторной работы это не обязательно, но преподаватели очень любят такие детали.

Резюме по курсу

Мы прошли полный путь создания надежного React-приложения:

  1. FSD: Разложили код по полочкам (shared, entities, features), чтобы не запутаться.
  2. Axios: Настроили interceptors для автоматического добавления токенов и их обновления (Refresh Token Flow).
  3. Forms: Подключили React Hook Form и Zod для валидации без боли.
  4. React Query: Реализовали работу с данными, которая автоматически обновляет интерфейс и кэширует результаты.

Теперь у вас есть полный набор инструментов («Survival Kit»), чтобы сдать лабораторную работу и, что важнее, писать качественный код на реальных проектах. Удачи в кодинге!

Улучшение UX: обработка ошибок, лоадеры и стилизация интерфейса

Улучшение UX: обработка ошибок, лоадеры и стилизация интерфейса

Поздравляю, коллеги. Мы прошли долгий путь. У нас есть архитектура FSD, настроенная авторизация с обновлением токенов, мощная валидация форм и кэширование данных. Ваше приложение работает, но... будем честны, выглядит оно пока «так себе».

Когда пользователь нажимает кнопку «Войти», ничего не происходит две секунды, а потом страница резко меняется. Если сервер упал, пользователь видит белый экран или бесконечную загрузку. Для лабораторной работы (и для жизни) это недопустимо.

Сегодня мы наведем лоск: добавим индикацию загрузки, красивые уведомления об ошибках и защитим приложение от падений. Это та самая «вишенка на торте», которая отличает поделку студента от работы инженера.

Глобальная обработка ошибок (Notifications)

Вспомните наш api.ts из второй статьи. Мы научили его обновлять токены, но что, если сервер вернет ошибку 500? Или 403? Сейчас мы просто пишем console.error, который видит только разработчик.

Пользователь должен знать, что происходит. Для этого мы внедрим систему Toast-уведомлений (всплывающие плашки).

Выбор инструмента

Для React существует множество библиотек: react-toastify, sonner, react-hot-toast. Для лабы возьмем классику — react-toastify.

npm install react-toastify

Настройка в FSD

Провайдер уведомлений должен находиться на слое App, так как он нужен всему приложению.

В src/app/providers/ToastProvider.tsx:

import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';

export const ToastProvider = () => {
  return <ToastContainer position="bottom-right" theme="colored" />;
};

Не забудьте подключить его в App.tsx.

Интеграция с Axios

Теперь самое интересное. Мы не будем вызывать toast.error в каждом компоненте вручную. Мы сделаем это централизованно в нашем HTTP-клиенте.

Вернемся в src/shared/api/api.ts и модифицируем Response Interceptor:

import { toast } from 'react-toastify';

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    // ... логика обновления токенов (из статьи 2) ...

    // Если это не ошибка авторизации (которую мы обрабатываем скрыто)
    // и не ошибка отмены запроса
    if (error.response?.status !== 401 && error.code !== 'ERR_CANCELED') {
      const message = error.response?.data?.message || 'Произошла непредвиденная ошибка';
      toast.error(message);
    }

    throw error;
  }
);

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

Индикация загрузки (Loaders & Skeletons)

Ничто так не бесит, как нажатие на кнопку без обратной связи. Пользователь начинает кликать яростно, отправляя 10 запросов на сервер.

Спиннеры (Spinners)

Спиннеры хороши для кнопок и небольших действий. Создадим переиспользуемый компонент в shared/ui/Spinner.

// src/shared/ui/Spinner/Spinner.tsx
export const Spinner = () => (
  <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
);

Используем его в кнопке (которую мы, надеюсь, тоже вынесли в shared/ui/Button):

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  isLoading?: boolean;
}

export const Button = ({ isLoading, children, ...props }: ButtonProps) => (
  <button
    {...props}
    disabled={isLoading || props.disabled}
    className="flex items-center justify-center gap-2 bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-70"
  >
    {isLoading && <Spinner />}
    {children}
  </button>
);

Теперь в формах (из статьи 3) мы просто передаем проп isLoading:

// Внутри LoginForm
<Button type="submit" isLoading={isSubmitting}>
  Войти
</Button>

Скелетоны (Skeletons)

Когда загружается целая страница или список карточек, спиннер выглядит одиноко. Лучше использовать Skeleton Loading — серые прямоугольники, имитирующие контент. Это создает ощущение, что данные «почти загрузились».

Пример простого скелетона на Tailwind:

// src/shared/ui/Skeleton/Skeleton.tsx
interface SkeletonProps {
  className?: string;
}

export const Skeleton = ({ className }: SkeletonProps) => (
  <div className={`animate-pulse bg-gray-200 rounded ${className}`} />
);

Использование в списке товаров (React Query):

// src/widgets/ProductList/ProductList.tsx
export const ProductList = () => {
  const { data, isLoading } = useProducts();

  if (isLoading) {
    return (
      <div className="grid grid-cols-3 gap-4">
        {/* Показываем 6 скелетонов пока грузится */}
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="border p-4 rounded">
            <Skeleton className="h-32 w-full mb-2" /> {/* Картинка */}
            <Skeleton className="h-6 w-3/4 mb-2" />   {/* Заголовок */}
            <Skeleton className="h-4 w-1/2" />        {/* Цена */}
          </div>
        ))}
      </div>
    );
  }

  return (
    // ... рендер реальных данных
  );
};

Error Boundary: Защита от белого экрана

Иногда ошибки случаются не в сети, а в коде рендера (например, попытка прочитать свойство undefined). В React это приводит к падению всего приложения — пользователь видит просто белый экран.

Чтобы этого избежать, используем паттерн Error Boundary (Предохранитель). Удобнее всего взять библиотеку react-error-boundary.

Обернем наше приложение в src/app/App.tsx:

import { ErrorBoundary } from 'react-error-boundary';

const ErrorFallback = ({ error, resetErrorBoundary }: any) => (
  <div className="flex flex-col items-center justify-center h-screen text-center">
    <h1 className="text-2xl font-bold text-red-600">Что-то пошло не так :(</h1>
    <pre className="text-sm text-gray-500 my-4">{error.message}</pre>
    <button
      onClick={resetErrorBoundary}
      className="bg-blue-500 text-white px-4 py-2 rounded"
    >
      Попробовать снова
    </button>
  </div>
);

export const App = () => {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <QueryClientProvider client={queryClient}>
        <Router />
      </QueryClientProvider>
    </ErrorBoundary>
  );
};

Теперь, если какой-то виджет сломается, пользователь увидит кнопку «Попробовать снова», а не пустоту.

Стилизация и Layout (FSD подход)

В требованиях сказано «CSS не страшный». Это значит, что не нужно делать дизайн уровня Apple, но отступы должны быть ровными, а цвета — приятными.

Layout (Макет)

В FSD макеты страниц часто выносят в слой Shared (если они совсем глупые) или Widgets (если содержат логику, например, Sidebar).

Создадим src/shared/ui/Layout/MainLayout.tsx:

import { Outlet } from 'react-router-dom';
import { Header } from '@/widgets/Header'; // Предполагаем, что Header уже есть

export const MainLayout = () => {
  return (
    <div className="min-h-screen bg-gray-50 flex flex-col">
      <Header />
      <main className="flex-1 container mx-auto p-4">
        {/* Outlet — это место, куда роутер подставит текущую страницу */}
        <Outlet />
      </main>
      <footer className="p-4 text-center text-gray-500 text-sm">
        © 2024 React Lab Survival
      </footer>
    </div>
  );
};

И применим его в роутере (src/app/router.tsx):

export const router = createBrowserRouter([
  {
    element: <MainLayout />,
    children: [
      { path: '/', element: <HomePage /> },
      { path: '/products', element: <ProductsPage /> },
    ]
  },
  {
    path: '/login',
    element: <LoginPage /> // Логин обычно без хедера и футера
  }
]);

Чек-лист готовности к сдаче

Мы закончили курс. Давайте проверим, что у вас есть в арсенале для защиты лабораторной:

  1. Архитектура: Проект разбит на shared, entities, features, widgets, pages. Импорты идут только снизу вверх.
  2. Сеть: Axios Instance с перехватчиками. Токены обновляются автоматически, ошибки показываются в Toast.
  3. Данные: React Query кэширует запросы. Нет лишних useEffect.
  4. Формы: React Hook Form + Zod. Валидация работает, типы строгие.
  5. UX: Есть лоадеры, скелетоны и защита от падений.

Этого набора достаточно, чтобы не просто сдать лабу, но и получить респект от преподавателя. Вы не просто написали код, который «вроде работает», вы создали поддерживаемую инженерную систему.

Удачи на защите! И помните: лучший код — это тот, который легко читать и сложно сломать.