Хотелось бы рассказать, как я использую @tanstack/react-query
в своих проектах при построении архитектуры приложения.
Все приложения, которые в той или иной мере имеют связь с сервером требуют выполнение стандартного набора действий:
1. Загружать данные;
2. Хранить эти данные;
3. Информировать о том что идет загрузка;
4. Информировать о том что произошла ошибка;
Давайте создадим базовый набор компонентов, методов, типов для построения такого приложения.
Инфраструктура
Будем считать, что у нашего приложения есть backend, и для нас он предоставляет следующие REST ручки.
Получение списка записей GET /list
Добавление нового элемента в список записей POST /list
Удаление элемента из списка записей DELETE /list/{id}
Редактирование элемента 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)
gen1lee
14.09.2024 12:34Ваша статья вышла почти одновременно с моей, и про похожие кейсы. Предлагаю рассмотреть библиотеку react-redux-cache, может зайдет больше чем react-query - там есть нормализация и полный доступ к хранилищу. Конечно же рад конструктивным комментариям.
profesor08
14.09.2024 12:34С каждой новой версией, все предыдущие туториалы становятся неактуальными, потому что меняется api и значение флагов, которые просто не совместимы с кодовой базой предыдущей версии. И ты либо переписываешь все, либо сидишь на старой версии. Лично я, после таких сюрпризов, по немного перебираюсь на `swr`, все то-же самое, только проще. Более того, мигрировать можно легко, заменив вызов хука, вся остальная логика продолжит работать как работала.
eyes_my_eyes Автор
14.09.2024 12:34согласен, этот подход удобен тем что ты меняешь содержание хука, а код не трогаешь
clerik_r
А почему просто не использовать классику?
Ну и дальше в компоненте
eyes_my_eyes Автор
На текущем месте работы тоже использую mobx state, сам я перешел на реакт из ангуляра (не по своей воле, так было нужно).
Не знаю почему, но разработчики react почему то не любят все что связанно с классами, и считают это чуждым. По этому я использую классический подход, который приемлем в react
eyes_my_eyes Автор
Ваше решение лаконично, и мне оно нравится!
clerik_r
Спасибо) Главное что код вы видите в первый раз, не знаете как написана по капотом asyncHelpers и ApIReq, но из их использования, сразу становится все понятно, как именно они работают/должны работать и что вообще происходит, какой будет результат, и какую гибкость все это дает)
clerik_r
Ну далеко не все конечно считают классы чем-то чуждым, всё напрямую зависит от их уровня и опыта, поэтому не стоит себя ущемлять в угоду тем, кто считает классы чем-то чужеродным
Фишка react в том, что там нет такого понятия как "приемлемо в react" или "не приемлемо в react", react это всего лишь библиотека для рендеринга с жизненным циклом, не более. А как ей пользоваться решаем мы)
eyes_my_eyes Автор
Огласен.
Приемлем понятие не реакта, а его комьюнити
clerik_r
Да и то, комьюнити реакта понятие очень сильно растяжимое. и оно очень сильно разнится по уровню) Какой-то части нравится одно, другой другое, третьей третье и т.п.
Вы только посмотрите на управление состоянием. кто-то вообще голый реакт юзает, кто-то redux, кто-то mobx, кто-то zustand, кто-то effector, кто-то recoil и т.д. и т.п.
Или например работа со стилями, css modules, styled-components, typestyle, tailwind и т.д. и т.п.
У комьюнити реакта нет общей позиции ни по одному вопросу, кроме как да, мы все юзаем реакт как таковой.
Поэтому говорить и думать о том, что разработчики react не любят X, за сейчас X возьмем классы, бессмысленно, потому что кому-то они нравятся, а кому-то нет, вот и всё. а не так, что так заведено. И уж тем более принимать решения как самому писать код и что-то использовать основываясь на так называемое комьюнити точно не стоит, т.к. по факту вы при этом не основываетесь вообще ни на чем, всё будет зависит лишь от того каким конкретно людям вы зададите вопрос и как на него конкретно они ответят.
eyes_my_eyes Автор
Я опираюсь на сугубо свой опыт.
Мы говорим об одном и том же, только разными словами