Хотелось бы рассказать, как я использую @tanstack/react-query в своих проектах при построении архитектуры приложения.

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

1. Загружать данные;
2. Хранить эти данные;
3. Информировать о том что идет загрузка;
4. Информировать о том что произошла ошибка;

Давайте создадим базовый набор компонентов, методов, типов для построения такого приложения.

Инфраструктура

Будем считать, что у нашего приложения есть backend, и для нас он предоставляет следующие REST ручки.

  1. Получение списка записей GET /list

  2. Добавление нового элемента в список записей POST /list

  3. Удаление элемента из списка записей DELETE /list/{id}

  4. Редактирование элемента PATCH /list/{id}

Для запросов мы будем использовать axios. https://axios-http.com

Создамим базовый набор сущностей в нашем приложении

Объявляем типы

/** Элемент списка */
export type TListItemDto = {
    /** Уникальный идентификатор */
    id: number;
    /** Наименование для отображения в интерфейсе */
    name: string;
    /** Содержимое элемента */
    content: string;
}

/** Список элементов */
export type TListResponseData = Array<TListItemDto>;

Создаем Http сервис

export const queryClient = new QueryClient();

function useListHttp() {
    const client = axios.create();

    const get = () => client
        .get<TListResponseData>('/list')
        .then(response => response.data);

    const add = (payload: Omit<TListItemDto, 'id'>) => client
        .post<TListItemDto>('/list', payload)
        .then(response => response.data);

    const remove = (id: TListItemDto['id']) => client
        .delete<void>(`/list/${id}`);

    const update = ({id, payload}: { id: TListItemDto['id'], payload: Omit<TListItemDto, 'id'> }) => client
        .patch<TListItemDto>(`/list/${id}`, payload)
        .then(response => response.data);

    return { get, add, remove, update};
}

Описываем хуки для работы с данными на основе @tanstack/react-query

/** Метод будет возвращать ключи для query и mutatuion, не обязателен, можно обойтись без него */
const getKey = (key, type: 'MUTATION' | 'QUERY') => `LIST_${key}__${type}`;

/** Список ключей */
const KEYS = {
    get: getKey('GET', 'QUERY'),
    add: getKey('ADD', 'MUTATION'),
    remove: getKey('REMOVE', 'MUTATION'),
    update: getKey('UPDATE', 'MUTATION'),
}

/** Получение списка */
export function useListGet() {
    const { get } = useListHttp();

    return useQuery({
        queryKey: [KEYS.get],
        queryFn: get,
        enabled: true,
        initialData: [],
    });
}

/** Добавление в список */
export function useListAdd() {
    const http = useListHttp();

    return useMutation({
        mutationKey: [KEYS.add],
        mutationFn: http.add,
        onSuccess: (newItem) => {
            /* После успешного создания нового элемента, обновляем список ранее загруженных добавленяя в него новой сущности без запроса к api */
            queryClient.setQueryData(
                [KEYS.get],
                (prev: TListResponseData) => [...prev, newItem]
            );
        },
    });
}

/** Удаление из списка */
export function useListRemove() {
    const { remove } = useListHttp();

    return useMutation({
        mutationKey: [KEYS.remove],
        mutationFn: remove,
        onSuccess: (_, variables: TListItemDto['id']) => {
            /* После успешного создания нового элемента, обновляем список ранее загруженных очищая из него удаленноую сущность без запроса к api */
            queryClient.setQueryData(
                [KEYS.get],
                (prev: TListResponseData) => prev.filter(item => item.id !== variables)
            );
        },
    });
}

/** Обновить элемент в списке */
export function useListUpdate() {
    const { update } = useListHttp();

    return useMutation({
        mutationKey: [KEYS.update],
        mutationFn: update,
        onSuccess: (response, variables: { id: TListItemDto['id'], payload: Omit<TListItemDto, 'id'> }) => {
            /* После успешного создания нового элемента, обновляем список элементов путем очистки из него удаленной сущности без запроса к api */
            queryClient.setQueryData(
                [KEYS.get],
                (prev: TListResponseData) => prev.map(item => item.id === variables.id ? response : item)
            );
        },
    });
}

Теперь переходим к компонентам

Будем считать что наше приложение вполне типичное и имеет следующую структуру

Схематическое описание структуры компонентов (я автор, я так вижу)
Схематическое описание структуры компонентов (я автор, я так вижу)

При нажатии на компонент мы будем отрисовывать форму редактирования, если ни один ListItem не выбран, форма будет работать на создание.

Общие компоненты используемые во всем прилежении

function ErrorMessage() {
    return 'В процессе загрузки данных произошла ошибка';
}

function PendingMessage() {
    return 'Загрузка...';
}

Теперь перейдем к основным компонентам

function List() {
    const id = useId();
    const { data, isFetching, isError } = useListGet();
    const listRemove = useListRemove();

    const handleEdit = (item: TListItemDto) => {
        // ... go to edit mode
    }
    const handleRemove = (itemId: TListItemDto['id']) => {
        listRemove.mutate(itemId);
    }

    if (isError) return <ErrorMessage />;

    if (isFetching) return <PendingMessage />;

    return data.map((item: TListItemDto) => (
        <div key={`${id}_${item.id}`} onClick={() => handleEdit(item)}>
            <div>id: {item.id}</div>
            <div>name: {item.name}</div>
            <div>content: {item.content}</div>

            <button onClick={() => handleRemove(item.id)}>
                {/* Если удаляется текущий элемент, отображаем информацию о процессе улаоения */}
                {listRemove.isPending && listRemove.variables === item.id ? 'Удаление' : 'Удалить'}
            </button>
        </div>
    ));
}

export default List;
export type TListItemFormProps = {
    item?: TListItemDto
}
function ListItemForm({ item }: TListItemProps) {
    const listUpdate = useListUpdate();
    const listAdd = useListAdd();

    const [name, setName] = useState(item?.name ?? '');
    const [content, setContent] = useState(item?.content ?? '');

    const isEditMode = item === null;
    const isPending = listAdd.isPending || listUpdate.isPending;
    
    const handleSubmit = () => {
        if (item) {
            listUpdate.mutate({
                id: item.id,
                payload: { name, content }
            });
        } else {
            listAdd.mutate({ name, content });
        }
    }

    if (isPending) return <PendingMessage />;

    return (
        <Fragment>
            <h1>{isEditMode ? 'Редактирование' : 'Создание'}</h1>
            <form onSubmit={handleSubmit}>
                <input type="text"
                       placeholder={'name'}
                       value={name}
                       onChange={(event) => setName(event.target.value)} />

                <input type="text"
                       placeholder={'content'}
                       value={content}
                       onChange={(event) => setContent(event.target.value)} />

                <button type='submit' disabled={isPending}>
                    {isPending ? 'Идет сохранение...' : 'Сохранить'}
                </button>
            </form>
        </Fragment>
    );
}

export default ListItemForm;

Итог

Мы построили базовое приложение, которое умеет загружать данные, информировать о статусе загрузки, ошибки и рисует загруженные данные.

Умеет их редактировать, создавать, удалять.

Без написания костылей для хранения данных и состояний этих данных.

Буду рад любому фидбэку, и жду вас для обсуждения в комментариях.

Комментарии (12)


  1. clerik_r
    14.09.2024 12:34

    А почему просто не использовать классику?

    import { makeAutpObservable } from "mobx";
    
    class SomeState {
      fetching = false;
      error = null;
      item: IItem = null;
    
      constuctor() {
        makeAutpObservable(this);
      }
      
      fetchData = async () => {
        this.fetching = true;
        const ah = asyncHelpers(this.fetchData);
        // Debounce + no race condition 300ms
        if (!await ah.debouce(300))  return;
      
        try {
          const item = await new ApiReq(`GET /api/v1/item/${this.itemId}`)
                                            .withCahe(60) // кэш 60сек
                                            // для отмены запросов
                                            .withAbort(ah.abortControllersArray)
                                            .send()
          // race condition check
          if (!ah.stillActual()) return;
    
          const itemComments = await new ApiReq(`GET /api/v1/item-comments/${item.commentsId}`)
                                            .withCahe(60) // кэш 60сек
                                            // для отмены запросов
                                            .withAbort(ah.abortControllersArray)
                                            .send()
          // race condition check
          if (!ah.stillActual()) return;
    
          const data:IItem = {
            ...item,
            comments: itemComments
          }
    
          this.item = data;
          this.error = null;
        } catch (e) {
          // race condition check
          if (!ah.stillActual()) return;
    
          this.error = e;
        } finally {
          // race condition check
          if (!ah.stillActual()) return;
    
          this.fetching = false;
        }
      }
    }

    Ну и дальше в компоненте

    const MyList = observer(() => {
      useState(() => { someState.fetchData(); });
      if (someState.fetching) return <Spinner />
    
      return (
        <div className={styles.list_container}">
          {someState.map(item => <div className={styles.list_item}>...</div>)}
        </div>
      )
    });


    1. eyes_my_eyes Автор
      14.09.2024 12:34

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


      1. eyes_my_eyes Автор
        14.09.2024 12:34

        Ваше решение лаконично, и мне оно нравится!


        1. clerik_r
          14.09.2024 12:34

          Ваше решение лаконично, и мне оно нравится!

          Спасибо) Главное что код вы видите в первый раз, не знаете как написана по капотом asyncHelpers и ApIReq, но из их использования, сразу становится все понятно, как именно они работают/должны работать и что вообще происходит, какой будет результат, и какую гибкость все это дает)


      1. clerik_r
        14.09.2024 12:34
        +2

        Не знаю почему, но разработчики react почему то не любят все что связанно с классами, и считают это чуждым

        Ну далеко не все конечно считают классы чем-то чуждым, всё напрямую зависит от их уровня и опыта, поэтому не стоит себя ущемлять в угоду тем, кто считает классы чем-то чужеродным

        По этому я использую классический подход, который приемлем в react

        Фишка react в том, что там нет такого понятия как "приемлемо в react" или "не приемлемо в react", react это всего лишь библиотека для рендеринга с жизненным циклом, не более. А как ей пользоваться решаем мы)


        1. eyes_my_eyes Автор
          14.09.2024 12:34

          Огласен.

          Приемлем понятие не реакта, а его комьюнити


          1. clerik_r
            14.09.2024 12:34

            Приемлем понятие не реакта, а его комьюнити

            Да и то, комьюнити реакта понятие очень сильно растяжимое. и оно очень сильно разнится по уровню) Какой-то части нравится одно, другой другое, третьей третье и т.п.

            Вы только посмотрите на управление состоянием. кто-то вообще голый реакт юзает, кто-то redux, кто-то mobx, кто-то zustand, кто-то effector, кто-то recoil и т.д. и т.п.

            Или например работа со стилями, css modules, styled-components, typestyle, tailwind и т.д. и т.п.

            У комьюнити реакта нет общей позиции ни по одному вопросу, кроме как да, мы все юзаем реакт как таковой.


            Поэтому говорить и думать о том, что  разработчики react  не любят X, за сейчас X возьмем классы, бессмысленно, потому что кому-то они нравятся, а кому-то нет, вот и всё. а не так, что так заведено. И уж тем более принимать решения как самому писать код и что-то использовать основываясь на так называемое комьюнити точно не стоит, т.к. по факту вы при этом не основываетесь вообще ни на чем, всё будет зависит лишь от того каким конкретно людям вы зададите вопрос и как на него конкретно они ответят.


            1. eyes_my_eyes Автор
              14.09.2024 12:34

              Я опираюсь на сугубо свой опыт.

              Мы говорим об одном и том же, только разными словами


  1. gen1lee
    14.09.2024 12:34

    Ваша статья вышла почти одновременно с моей, и про похожие кейсы. Предлагаю рассмотреть библиотеку react-redux-cache, может зайдет больше чем react-query - там есть нормализация и полный доступ к хранилищу. Конечно же рад конструктивным комментариям.


    1. eyes_my_eyes Автор
      14.09.2024 12:34

      Спасибо, ознакомлюсь


  1. profesor08
    14.09.2024 12:34

    С каждой новой версией, все предыдущие туториалы становятся неактуальными, потому что меняется api и значение флагов, которые просто не совместимы с кодовой базой предыдущей версии. И ты либо переписываешь все, либо сидишь на старой версии. Лично я, после таких сюрпризов, по немного перебираюсь на `swr`, все то-же самое, только проще. Более того, мигрировать можно легко, заменив вызов хука, вся остальная логика продолжит работать как работала.


    1. eyes_my_eyes Автор
      14.09.2024 12:34

      согласен, этот подход удобен тем что ты меняешь содержание хука, а код не трогаешь