В приложениях с REST архитектурой существует ряд проблем:
повторяющийся код при работе с состоянием приложения;
костыли и велосипеды при обработке результатов и состояний запросов;
отсутствие стандартного механизма кеширования полученных на клиенте данных;
одновременные запросы за одними и теми же данными;
сложности реализации pessimistic/optimistic обновления состояний.
В клаудных микросервисах Netcracker мы решаем эти проблемы с помощью GraphQl & apollo. Однако есть изрядное количество приложений, использующих классический REST подход для общения с сервером. Хорошим решением для них является Redux Toolkit Query.
Netcracker стремится оптимизировать разработку клиентской части приложений на React. В начале пути мы использовали JavaScript + redux + axios для работы с состоянием приложения. В целом все было неплохо, вот только количество повторяющегося кода в redux зашкаливало, да и отсутствие типизации с болью отзывалось при любых UI изменениях. На помощь пришли Typescript и Redux-toolkit, украсив типизацией и слайсами наши front-end будни.
В крупных компаниях решение стандартных проблем с REST обычно отнимает большое количество времени и сил разработчиков. Настало время это исправить с помощью Redux toolkit query.
Документация Redux toolkit query хороша в теоретической части, но не покрывает некоторых особенностей, с которыми мы сталкиваемся на реальных проектах.
На самом redux и redux-toolkit останавливаться не будем (про редакс, про redux toolkit).
Также к вашему вниманию: Базовый пример стандартного CRA приложения с RTK Query.
Обратите внимание на минимальное количество кода, необходимое при работе с состоянием приложения.
Пример использования api
// Пример использования api
export const exampleApi = commonApi.injectEndpoints({
endpoints: build => ({
fetchExampleList: build.query<ExampleModel[], number | void>({
query: (limit: number = 5) => ({
url: '/example',
params: {
limit,
},
}),
providesTags: result => [{ type: 'Example', id: 'List' }],
}),
createExample: build.mutation<ExampleModel, { example: Partial<ExampleModel> & { limit?: number } }>({
query: ({ example }) => ({
url: '/example',
method: 'POST',
body: example,
}),
async onQueryStarted({ example }, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
dispatch(
exampleApi.util.updateQueryData('fetchExampleList', example.limit, draft => {
draft.unshift(data);
})
);
} catch (e) {
console.error('userApi createUser error', e);
}
},
}),
updateExample: build.mutation<ExampleModel, { example: ExampleModel }>({
query: ({ example }) => ({
url: `/example`,
method: 'PUT',
body: example,
}),
invalidatesTags: ['Example'],
}),
deleteExample: build.mutation<ExampleModel, { example: ExampleModel }>({
query: ({ example }) => ({
url: `/example/${example.id}`,
method: 'DELETE',
}),
invalidatesTags: ['Example'],
}),
}),
});
Также на созданные с помощью RTK Query хуки, позволяющие стандартизовать обработку результатов и состояний запросов:
Пример автоматически сгенерированных хуков
const { data: examples = [], isLoading: examplesLoading } = exampleApi.useFetchExampleListQuery();
const [createExampleMutation, { isLoading: createExampleLoading }] = exampleApi.useCreateExampleMutation();
const [deleteExampleMutation, { isLoading: deleteExampleLoading }] = exampleApi.useDeleteExampleMutation();
const [updateExampleMutation, { isLoading: updateExampleLoading }] = exampleApi.useUpdateExampleMutation();
Приступим к рассмотрению неявных особенностей данной библиотеки:
1) Использование common.api.ts
Следует создать common.api.ts в самом начале. (Тут nota bene, на момент написания статьи в RTK (версии === 1.6.2) typescript не генерировал хуки в случае импорта createApi не из '@reduxjs/toolkit/dist/query/react' и typescript версии < 4.1).
CommonApi – сущность, которая будет хранить общие настройки. Ее удобно расширять остальными *api в приложении, которые автоматически получат baseUrl (будет добавляться ко всем запросам), headers (см. пример) и tagTypes (для инвалидации кешей).
Пример создания commonApi
// src/store/common.api.ts
export const commonApi = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: BASE_URL,
prepareHeaders: headers => {
headers.set('Content-Type', 'application/json;charset=UTF-8');
headers.set('Authorization', 'anonymous');
return headers;
},
}),
tagTypes: ['Example'],
endpoints: _ => ({}),
});
// src/store/store.ts
const rootReducer = combineReducers({
…
[commonApi.reducerPath]: commonApi.reducer,
…
});
export const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware => getDefaultMiddleware().concat(commonApi.middleware),
…
});
2) Расширение commonApi чанками.
Каждый новый *api создаем, расширяя базовый commonApi, при этом больше не надо изменять store.ts, что очень удобно!
// src/store/example/example.api.ts
export const exampleApi = commonApi.injectEndpoints({
endpoints: …
3) Pessimistic & Optimistic Updates
В интернете обычно представлены примеры запросов RTK query с последующим сбросом его кешей. Рассмотрим случай добавления/удаления сущности. После каждого подобного запроса RTK query отправит дополнительный гет запрос, чтобы получить самое последнее состояние. На практике же дополнительный запрос ни к чему. В зависимости от вашего мировоззрения (шутка) следует использовать pessimistic/optimistic обновление данных в кеш. Это избавит вас от ненужных запросов. В основном используем pessimistic обновление, после ответа сервера.
Пример pessimistic обновления
createExample: build.mutation<ExampleModel, { example: Partial<ExampleModel> & { limit?: number } }>({
query: ({ example }) => ({
url: '/example',
method: 'POST',
body: example,
}),
async onQueryStarted({ example }, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
dispatch(
exampleApi.util.updateQueryData('fetchExampleList', example.limit, draft => {
draft.unshift(data);
})
);
} catch (e) {
console.error('exampleApi createExample error', e);
}
},
}),
4) Разница между onQueryStarted и queryFn
Часто при работе с асинхронными вызовами, до и после отправки запроса, необходимо осуществить дополнительное действие. Для этих целей стоит использовать onQueryStarted. Модифицировать запрос не получится, однако возможно отследить его состояние с помощью queryFulfilled.
Пример onQueryStarted
fetchEntity: build.query<EntityModel, { id: string }>({
query: ({ id }) => ({
url: getEntityUrl(id),
}),
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
try {
const result = await queryFulfilled;
dispatch(setEntityAction(result.data));
} catch (e) {
await const { unsubscribe } = dispatch(entityApi.endpoints.postEntityIdOnBE.initiate({ entityId: '' }));
unsubscribe();
console.error('fetchEntity error', e);
}
},
}),
Если же требуется полностью контролировать запрос, добавить к нему хедеры, формировать тело запроса с использованием текущего состояния, сделать кастомный action (возможно вообще без запроса) – в этих случаях стоит использовать queryFn и встроенную обертку браузерного fetch – fetchWithBQ
Пример queryFn
deanonymizeCustomer: build.mutation<
CustomerModel,
{ customer: CustomerInputModel }
>({
async queryFn({ customer }, { getState, dispatch }, extraOptions, fetchWithBQ) {
const state = getState() as RootState;
const customerId = state.customer?.id;
if (!customerId) throw new Error('Deanonymize customer error, no customerId');
const body = getDeanonymizeCustomerData(customer);
const result = await fetchWithBQ({
url: getDeanonCustomerUrl(customerId),
method: 'POST',
body,
});
if (result.error) throw result.error;
const data = result.data as CustomerModel;
return { data };
},
}),
5) RTK query и его место в приложении
Стоит отметить, что RTK query не заменит работу с состоянием приложения полностью. К нему стоит относиться, как к помощнику для REST запросов. Этот помощник умеет решать ряд проблем и предоставляет удобный инструментарий для работы с кеш, что позволяет избавиться от большого количества повторяющегося кода. Однако в больших приложениях не все метаморфозы состояния линейны. Представим сценарий, что всему приложению нужна информация о пользователе. При этом гет запрос за пользователем зависит от нескольких параметров (locationId, distributionId и тд). Чтобы получить часть состояния с этим пользователем в RTK query, необходимо знать все параметры. Что делать если их неудобно получать в контейнере, которому нужна информация о пользователе? Если контейнер, делающий запрос за пользователем, уже не на странице? Если понадобится только id последнего полученного юзера? В таких случаях информацию стоит хранить в стандартном слайсе redux-toolkit и получать обычными селекторами, не перегружая код и умы разработчиков.
В итоге RTK query:
помог уменьшить количество кода для работы с состоянием приложения;
избавил нас от бойлерплейтов и кастомного кода при трекинге состояний и результатов запросов;
решил проблему одновременных запросов за одними и теми же данными;
из коробки позволил удобно работать с кеширования полученных данных на клиенте;
удобно реализует pessimistic/optimistic обновления состояний.