>

Статьи>

Фабрика запросов @tanstack/react-query на примере FSD и axios

Фабрика запросов @tanstack/react-query на примере FSD и axios

Logo

Фабрика запросов @tanstack/react-query на примере FSD и axios

В современном мире разработки фронтенда, оптимизация работы с данными — это ключ к созданию отзывчивых и масштабируемых приложений. Использование библиотек для работы с асинхронными запросами, таких как @tanstack/react-query и axios, позволяет организовать удобную систему для работы с REST API, tRPC или GraphQL.

При разработке приложений, нам часто требуется выполнять одни и те же действия для различных сущностей API: получать список элементов, получать один элемент по ID, создавать новый элемент, совершать обновления и удаления.

Эти операции называются CRUD (Create, Read, Update, Delete). Я покажу, как можно реализовать универсальные CRUD-хуки с использованием вышеперечисленных библиотек в функциональном стиле на базе паттерна фабрика. Вместо того чтобы дублировать код для каждой сущности, мы можем использовать генерацию хуков, которая значительно сокращает количество копипаст кода и делает его гибким и переиспользуемым. Этот подход выглядит легко масштабируемым, универсальным и применимым к любому API.

@tanstack/react-query и axios

  • @tanstack/react-query, которая до недавнего времени называлась react-query, — это библиотека для управления состоянием запросов, кэширования данных и синхронизации их с сервером. Она позволяет значительно улучшить производительность и упрощает работу с асинхронными запросами, абстрагируя логику работы с API от логики отображения данных в компонентах React.

  • axios — это популярная библиотека для выполнения HTTP-запросов. Она удобна своей гибкостью и простотой использования, в том числе при работе с асинхронными операциями.

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

Реализация CRUD-хуков в функциональном стиле

Итак, давайте приступим к реализации. Мы создадим функцию createCrudHooks, которая для любой сущности будет возвращать набор CRUD-хуков.

Шаг 1: Подключаем необходимые библиотеки

Для работы с запросами и кэшированием установим @tanstack/react-query и axios, не забыв перед этим перейти в директорию нашего проекта:

cd ./my-awesome-project
npm i axios react-query

Шаг 2: Создаём основную функцию

Теперь создадим функцию createCRUDHooks, которая принимает базовый URL и имя сущности (например, users или posts). Функция будет возвращать набор хуков для выполнения CRUD-операций.

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'

import axios, { AxiosResponse } from 'axios'

// Функция для создания CRUD-хуков
const createCrudHooks = <T>(baseUrl: string, entity: string) => {
  const api = axios.create({
    baseURL: baseUrl,
  })

  // Fetch All (GET) - получить всё
  const useFetchAll = (queryKey: string) => {
    return useQuery<T[]>(queryKey, async () => {
      const response: AxiosResponse<T[]> = await api.get(`/${entity}`)
      return response.data
    })
  }

  // Fetch One by ID (GET) - получить по id сущности
  const useFetchOne = (queryKey: string, id: string | number) => {
    return useQuery<T>([queryKey, id], async () => {
      const response: AxiosResponse<T> = await api.get(`/${entity}/${id}`)
      return response.data
    })
  }

  // Create (POST) - создать сущность
  const useCreate = () => {
    const queryClient = useQueryClient()

    return useMutation(
      async (data: Partial<T>) => {
        const response: AxiosResponse<T> = await api.post(`/${entity}`, data)
        return response.data
      },
      {
        onSuccess: () => {
          queryClient.invalidateQueries(entity)
        },
      }
    )
  }

  // Update (PUT) - обновить сущность
  const useUpdate = () => {
    const queryClient = useQueryClient()

    return useMutation(
      async ({ id, data }: { id: string | number; data: Partial<T> }) => {
        const response: AxiosResponse<T> = await api.put(
          `/${entity}/${id}`,
          data
        )
        return response.data
      },
      {
        onSuccess: () => {
          queryClient.invalidateQueries(entity)
        },
      }
    )
  }

  // Delete (DELETE) - удалить сущность
  const useDelete = () => {
    const queryClient = useQueryClient()

    return useMutation(
      async (id: string | number) => {
        const response: AxiosResponse<void> = await api.delete(
          `/${entity}/${id}`
        )
        return response.data
      },
      {
        onSuccess: () => {
          queryClient.invalidateQueries(entity)
        },
      }
    )
  }

  return {
    useFetchAll,
    useFetchOne,
    useCreate,
    useUpdate,
    useDelete
  }
}

Про типизацию применимо к @tanstack/react-query можно почитать в статье Type-safe React Query.

Разберем подробнее, какие хуки возвращает функция createCrudHooks:

Хук useFetchAll

Этот хук делает GET-запрос, который получает список элементов определённой сущности, например, список пользователей (users) и использует хук useQuery из библиотеки @tanstack/react-query для кэширования и отслеживания состояния запроса. Он возвращает массив элементов, который можно использовать в любом React-компоненте для последующего рендера или каких-либо манипуляций. Функцию можно расширить для передачи дополнительных параметров запроса, например, для фильтрации результатов.

Хук useFetchOne

Этот хук получает один элемент по его ID (уникальному идентификатору). Он также использует useQuery, но принимает второй аргумент — идентификатор элемента. Это позволяет получать данные по конкретному элементу сущности.

Хук useCreate

Этот хук используется для создания новых элементов. Он вызывает POST-запрос к API и после успешного создания элемента вызывает invalidateQueries, чтобы актуализировать кэш, автоматически перезагружая данные в других компонентах, где используются наши хуки.

Хук useUpdate

Для обновления элемента используется PUT-запрос. Хук принимает объект с ID элемента и данными для обновления. После успешного обновления кэш данных также обновляется.

Хук useDelete

Этот хук удаляет элемент по его ID с помощью DELETE-запроса и обновляет кэш данных после удаления.

Шаг 3: Тесты

Давайте добавим тесты, чтобы убедиться, что наш код работает корректно и без ошибок.

Для начала настроим тестовое окружение, воспользуемся библиотеками:

  • @testing-library/react — для тестирования компонентов;
  • @testing-library/jest-dom — предоставляет дополнительные методы для ассертов (например, toBeInTheDocument);
  • vitest — тестовый раннер и фреймворк для написания тестов;
  • axios-mock-adapter — для мокирования запросов через axios.
npm i @testing-library/react @testing-library/jest-dom vitest axios-mock-adapter -D

Для запуска тестов добавьте следующий скрипт в package.json:

"scripts": {
  "test": "vitest"
}

Теперь вы можете запускать тесты:

npm run test

Создадим файл для тестов, например, crudHooks.test.tsx:

import { QueryClient, QueryClientProvider } from 'react-query'
import { act, renderHook } from '@testing-library/react'
import axios from 'axios'
import MockAdapter from 'axios-mock-adapter'
// Путь к вашему коду с хуками
import { createCrudHooks } from './crudHooks'

// Мок-адаптер для axios
const mock = new MockAdapter(axios)

// Настройка react-query
const createTestQueryClient = () => {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
      },
    },
  })
}

// Обертка для тестов с QueryClientProvider
const wrapper = ({ children }: { children: React.ReactNode }) => {
  const queryClient = createTestQueryClient()
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  )
}

// Пример сущности User
interface User {
  id: number
  name: string
  email: string
}

// Создаем хуки для пользователей
const { useFetchAll, useFetchOne, useCreate, useUpdate, useDelete } =
  createCrudHooks<User>('https://api.example.io', 'users')

// Очистка моков перед каждым тестом
beforeEach(() => {
  mock.reset()
})

describe('CRUD Hooks', () => {
  it('should fetch all users successfully', async () => {
    // Мок данных
    const mockData = [
      { id: 1, name: 'Ivan Green', email: 'ivan@example.io' },
      { id: 2, name: 'Inna Green', email: 'inna@example.io' },
    ]

    // Мок ответа от API
    mock.onGet('/users').reply(200, mockData)

    // Рендерим хук useFetchAll
    const { result, waitFor } = renderHook(() => useFetchAll('users'), {
      wrapper,
    })

    // Ждем успешного выполнения запроса
    await waitFor(() => result.current.isSuccess)

    // Проверяем, что данные корректны
    expect(result.current.data).toEqual(mockData)
  })

  it('should fetch one user by ID', async () => {
    const mockUser = { id: 1, name: 'Ivan Green', email: 'ivan@example.io' }

    // Мок ответа от API
    mock.onGet('/users/1').reply(200, mockUser)

    const { result, waitFor } = renderHook(() => useFetchOne('users', 1), {
      wrapper,
    })

    await waitFor(() => result.current.isSuccess)

    expect(result.current.data).toEqual(mockUser)
  })

  it('should create a new user', async () => {
    const newUser = { id: 3, name: 'Sam Smith', email: 'sam@example.io' }

    // Мок POST запроса
    mock.onPost('/users').reply(201, newUser)

    const { result, waitFor } = renderHook(() => useCreate(), { wrapper })

    act(() => {
      result.current.mutate({ name: 'Sam Smith', email: 'sam@example.io' })
    })

    await waitFor(() => result.current.isSuccess)

    expect(result.current.data).toEqual(newUser)
  })

  it('should update a user', async () => {
    const updatedUser = {
      id: 1,
      name: 'Ivan Updated',
      email: 'ivan.updated@example.io',
    }

    // Мок PUT запроса
    mock.onPut('/users/1').reply(200, updatedUser)

    const { result, waitFor } = renderHook(() => useUpdate(), { wrapper })

    act(() => {
      result.current.mutate({
        id: 1,
        data: { name: 'Ivan Updated', email: 'Ivan.updated@example.io' },
      })
    })

    await waitFor(() => result.current.isSuccess)

    expect(result.current.data).toEqual(updatedUser)
  })

  it('should delete a user', async () => {
    // Мок DELETE запроса
    mock.onDelete('/users/1').reply(200)

    const { result, waitFor } = renderHook(() => useDelete(), { wrapper })

    act(() => {
      result.current.mutate(1)
    })

    await waitFor(() => result.current.isSuccess)

    // Проверяем, что запрос был успешным
    expect(result.current.isSuccess).toBe(true)
  })
})

Мы создали тесты для нашего набора CRUD-хуков с использованием react-testing-library, axios-mock-adapter и vitest. Это делает наш код надёжным и протестированным, что важно для разработки масштабируемых и поддерживаемых приложений. Тестирование асинхронных операций становится значительно проще благодаря мокированию запросов и инструментам для работы с хуками.

Пример использования в React-компоненте

Теперь, когда мы создали универсальные хуки, давайте посмотрим, как ими пользоваться в реальном компоненте:

interface User {
  id: number
  name: string
  email: string
}

// Можно вынести в отдельный файл для переиспользования
const { useFetchAll, useCreate, useUpdate, useDelete } = createCrudHooks<User>(
  'https://api.example.io',
  'users'
)

type UserProps = {
  user: User
}

const UserItem = ({ user }: UserProps) => {
  const { mutate: updateUser } = useUpdate()
  const { mutate: deleteUser } = useDelete()

  const handleUpdate = () => {
    updateUser({ id: user.id, data: { name: 'Иван' } })
  }

  const handleDelete = () => {
    deleteUser(user.id)
  }

  return (
    <li key={user.id}>
      {user.name} - {user.email}
      <button onClick={handleUpdate}>Обновить имя</button>
      <button onClick={handleDelete}>Удалить</button>
    </li>
  )
}

export const Users = () => {
  const { data: users, isLoading } = useFetchAll('users')
  const { mutate: createUser } = useCreate()

  const handleCreateUser = () => {
    createUser({ name: 'Даша', email: 'dasha@example.io' })
  }

  if (isLoading) return <div>Загрузка пользователей...</div>

  return (
    <div>
      <h1>Пользователи</h1>
      <ul>
        {users?.map((user: User) => (
          <UserItem key={user.id} user={user} />
        ))}
      </ul>
      <button onClick={handleCreateUser}>Создать пользователя</button>
    </div>
  )
}

Что не так с этим подходом?

При использовании оберток для хуков можно получить проблемы с типизацией, которые описаны в обсуждении на github. Один из вариантов — это использовать фабрики запросов, которые мы рассмотрим далее. Можно написать свою фабрику или воспользоваться библиотекой Query Key Factory.

Пример использования фабрики запросов в приложении, построенном на принципах Feature-Sliced Design (FSD)

Когда проект масштабируется, становится важным обеспечить его модульность и структурированность. Один из вариантов архитектуры — Feature-Sliced Design (FSD), которая помогает организовать приложение по функциональному принципу, выделяя слои ответственности. Рассмотрим, как применить FSD для проекта, использующего @tanstack/react-query.

Что такое Feature-Sliced Design?

FSD (Feature-Sliced Design) — это подход к архитектуре, при котором приложение делится на независимые модули (фичи) по функциональному принципу. Главная цель — улучшить масштабируемость, поддерживаемость и читаемость проекта. FSD помогает избежать разрастания кодовой базы и упрощает тестирование.

Уровни FSD

  • App (Приложение): Глобальные конфигурации, маршрутизация и провайдеры.
  • Pages (Страницы): Полные страницы или большие части страницы при вложенном роутинге.
  • Widgets (Виджеты): Большие самодостаточные куски функциональности или интерфейса, обычно реализующие целый пользовательский сценарий.
  • Features (Фичи): Повторно используемые реализации целых фич продукта, то есть действий, приносящих бизнес-ценность пользователю.
  • Entities (Сущности): Базовые объекты бизнес-логики, такие как пользователи, заказы и товары.
  • Shared (Общие ресурсы): Общие модули, такие как UI-компоненты, утилиты и API-хелперы.

Фабрика запросов и FSD

Теперь рассмотрим архитектуру проекта с использованием Фабрики запросов и подхода FSD.

Структура проекта:

src/
├── app/                   // Глобальные настройки и провайдеры
├── entities/              // Сущности
│   └── pokemon/
│       ├── api/
│       ├── model/
│       └── ui/
├── features/              // Фичи
├── widgets/               // Виджеты
├── pages/                 // Страницы (например, PokemonsPage)
└── shared/                // Общие компоненты и утилиты
    ├── api/
    ├── config/
    └── ui/

Shared (Общие ресурсы)

К Shared относятся общие компоненты, утилиты, конфигурации и общие хелперы для API. Напишем функцию createQueries, которая будет являться фабрикой запросов, представим, что используем простое CRUD API для общения с сервером:

import { keepPreviousData, queryOptions } from '@tanstack/react-query';
import * as qs from 'qs';
import { createQueryFn } from '../../api/createQueryFn';
import { createMutationFn } from '../../api/createMutationFn';
import { createDeleteMutationFn } from '../../api/createDeleteMutationFn';
import { createUpdateMutationFn } from '../../api/createUpdateMutationFn';

// Передаём дженерики для того, чтобы при последующем написании кода у нас корректно отображались типы
export const createQueries = <
  CreateResponse,
  CreateBody,
  ReadResponse,
  ReadOneResponse,
  UpdateResponse,
  UpdateBody,
  DeleteResponse,
  DeleteParams
>(
  entity: string
) => ({
  all: () =>
    queryOptions({
      queryKey: [entity],
    }),
  create: () => ({
    mutationKey: [entity],
    mutationFn: (body: CreateBody) =>
      createMutationFn<CreateResponse, CreateBody>({
        path: `/${entity}`,
        body,
      }),
    placeholderData: keepPreviousData,
  }),
  read: (filters) =>
    queryOptions({
      queryKey: [entity, filters],
      queryFn: () =>
        createQueryFn<ReadResponse>({
          path: `/${entity}?${qs.stringify(filters)}`,
        }),
      placeholderData: keepPreviousData,
    }),
  readOne: ({ id }) =>
    queryOptions({
      queryKey: [entity, id],
      queryFn: () =>
        createQueryFn<ReadOneResponse>({
          path: `/${entity}/${id}`,
        }),
      placeholderData: keepPreviousData,
    }),
  update: () => ({
    mutationKey: [entity],
    mutationFn: ({ id, body }) =>
      createUpdateMutationFn<UpdateResponse, UpdateBody>({
        path: `/${entity}/${id}`,
        body,
      }),
    placeholderData: keepPreviousData,
  }),
  delete: () => ({
    mutationKey: [entity],
    mutationFn: (params: DeleteParams) =>
      createDeleteMutationFn<DeleteResponse>({
        path: `/${entity}/${params.id}`,
      }),
    placeholderData: keepPreviousData,
  }),
});

Функция возвращает объект с конфигурациями запросов для основных CRUD-операций.

Создадим файл для тестов createQueries.test.ts:

import { describe, it, expect, vi } from 'vitest';
import { createQueries } from './createQueries.ts';
import * as api from '../../api/createQueryFn';
import * as mutationApi from '../../api/createMutationFn';

vi.mock('../../api/createQueryFn', () => ({
  createQueryFn: vi.fn(),
}));

vi.mock('../../api/createMutationFn', () => ({
  createMutationFn: vi.fn(),
}));

vi.mock('../../api/createDeleteMutationFn', () => ({
  createDeleteMutationFn: vi.fn(),
}));

vi.mock('../../api/createUpdateMutationFn', () => ({
  createUpdateMutationFn: vi.fn(),
}));

describe('createQueries', () => {
  const entity = 'user';

  it('should return correct query options for "all"', () => {
    const queries = createQueries(entity);
    const result = queries.all();

    expect(result.queryKey).toEqual([entity]);
  });

  it('should create mutation for "create"', () => {
    const queries = createQueries(entity);
    const body = { name: 'Alexander' };

    queries.create().mutationFn(body);

    expect(mutationApi.createMutationFn).toHaveBeenCalledWith({
      path: `/${entity}`,
      body,
    });
  });

  it('should return correct query options for "read"', () => {
    const queries = createQueries(entity);
    const filters = { page: 1 };

    queries.read(filters).queryFn();

    expect(api.createQueryFn).toHaveBeenCalledWith({
      path: `/${entity}?page=1`,
    });
  });

  it('should return correct query options for "readOne"', () => {
    const queries = createQueries(entity);
    const id = 123;

    queries.readOne({ id }).queryFn();

    expect(api.createQueryFn).toHaveBeenCalledWith({
      path: `/${entity}/${id}`,
    });
  });

  it('should create mutation for "update"', () => {
    const queries = createQueries(entity);
    const id = 123;
    const body = { name: 'Updated' };

    queries.update().mutationFn({ id, body });

    expect(mutationApi.createUpdateMutationFn).toHaveBeenCalledWith({
      path: `/${entity}/${id}`,
      body,
    });
  });

  it('should create mutation for "delete"', () => {
    const queries = createQueries(entity);
    const params = { id: 123 };

    queries.delete().mutationFn(params);

    expect(mutationApi.createDeleteMutationFn).toHaveBeenCalledWith({
      path: `/${entity}/${params.id}`,
    });
  });
});

Теперь наша функция протестирована.

Entities (Сущности)

Сущности — это основные доменные объекты, с которыми работает приложение, например, User или Pokemon. Для каждой сущности будет свой модуль, включающий описание типов данных, CRUD-хуки, UI-компоненты для отображения и, возможно, адаптеры для взаимодействия с API.

Пример структуры сущности Pokemon:

src/
└── entities/
    └── pokemon/
        ├── api/
        │   ├── pokemon.query.ts      // Queries для Pokemon
        │   ├── usePokemon.tsx        // Хук получения одного Pokemon
        │   ├── usePokemonCreate.tsx  // Хук создания Pokemon
        │   ├── usePokemonDelete.tsx  // Хук удаления Pokemon
        │   ├── usePokemonList.tsx    // Хук получения списка Pokemon
        │   └── usePokemonUpdate.tsx  // Хук обновления Pokemon
        └── model/
            └── Pokemon.ts            // Описание типов данных сущности Pokemon
        └── ui/
            ├── PokemonList.tsx       // Компонент для отображения списка Pokemon
            └── PokemonCard.tsx       // Компонент для отображения отдельного Pokemon
  • api/usePokemon*.ts: Реализация CRUD-хуков для работы с API пользователей.
  • model/Pokemon.ts: Описание типов данных сущности Pokemon.
  • ui/PokemonList.tsx и ui/PokemonCard.tsx: Компоненты интерфейса для работы с сущностью пользователя.

Пример pokemon.query.ts с использованием createQueries:

import { createQueries } from '../../../shared/lib/createQueries/createQueries';
import {
  CreatePokemonResponse,
  CreatePokemonBody,
  ReadPokemonResponse,
  ReadOnePokemonResponse,
  UpdatePokemonResponse,
  UpdatePokemonBody,
  DeletePokemonResponse,
  DeletePokemonParams,
} from '../model/Pokemon';

export const pokemonQueries = createQueries<
  CreatePokemonResponse,
  CreatePokemonBody,
  ReadPokemonResponse,
  ReadOnePokemonResponse,
  UpdatePokemonResponse,
  UpdatePokemonBody,
  DeletePokemonResponse,
  DeletePokemonParams
>('pokemon');

В createQueries передаём необходимые для корректной типизации типы и название сущности согласно контракту с бэком. И используем pokemonQueries для создания необходимых CRUD-хуков на примере usePokemonList:

import { useQuery } from '@tanstack/react-query';
import { pokemonQueries } from './pokemon.query';

type Props = {
  limit: number;
  offset: number;
};

export const usePokemonList = ({ limit, offset }: Props) => {
  return useQuery({
    ...pokemonQueries.read({ limit, offset }),
  });
};

Получаем все необходимые для работы приложения CRUD-хуки, которые можно использовать в UI-компонентах или других слоях приложения, что делает структуру кода более инкапсулированной и адаптированной к FSD.

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

Features (Фичи)

Фичи представляют собой конкретные действия пользователя, приносящие бизнес-ценность. Например, редактирование или создание нового покемона. Эти фичи могут использовать сущности и оборачивать их в дополнительные бизнес-правила, если это необходимо.

Пример структуры фичи create-pokemon:

src/
└── features/
    └── create-pokemon/
        ├── model/
        └── ui/
  • model/: Может содержать хук или функцию для создания пользователя (например, используя usePokemonCreate из entities/pokemon/api).
  • ui/: Компоненты, которые визуализируют форму добавления пользователя или UI для подтверждения добавления.
import { usePokemonCreate } from '../../../entities/pokemon/api/usePokemonCreate';
import { CreatePokemonBody } from '../../../entities/pokemon/model/Pokemon';

type Props = {
  pokemon: CreatePokemonBody;
};

export const CreatePokemon = ({ pokemon }: Props) => {
  const { mutate: createPokemon } = usePokemonCreate();

  const handleCreate = () => {
    createPokemon({
      name: pokemon.name,
    });
  };

  return (
    <button
      onClick={handleCreate}
      className="bg-green-500 text-white p-2 rounded hover:bg-green-600 transition-colors"
    >
      Создать {pokemon.name}
    </button>
  );
};

Использование компонента:

<CreatePokemon pokemon={{ name: 'Pikachu' }} />

Widgets (Виджеты)

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

Наиболее подробно вы можете познакомиться с данным подходом в демо-проекте.

Выводы

Мы реализовали гибкую и переиспользуемую систему CRUD-хуков с помощью @tanstack/react-query и axios. Теперь вы можете легко использовать эти хуки для любой сущности вашего API, значительно упростив код и улучшив читаемость.

Этот подход помогает легко масштабировать приложение и минимизировать дублирование кода. Применение принципов FSD помогает структурировать проект, делая его более масштабируемым и поддерживаемым. Комбинирование этих подходов позволяет создавать гибкие приложения, в которых модульность и независимость компонентов делают проект удобным в поддержке и масштабировании.

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

Полезные ссылки:

Тарас Протченко, команда Аврора Центр

Мы используем cookies для персонализации сайта и его более удобного использования. Вы можете запретить cookies в настройках браузера.

Пожалуйста ознакомьтесь с политикой использования cookies.