Сайд-эффекты и RTK Query: кэш, теги, оптимизм
В прошлом уроке Echo переехал на единый store, но каналы и сообщения мы всё ещё
грузим голым fetch где-то в эффектах компонентов. Это последний долг. Сообщения
это server state, а ему, как мы выяснили на карте состояния,
нужен не store, а кэш: с инвалидацией, дедупликацией и оптимистичными апдейтами.
В этом уроке мы закроем этот долг двумя инструментами Redux Toolkit: сначала
разберём, как Redux вообще пускает к себе сайд-эффекты (middleware, thunk,
listener), а потом отдадим всю работу с сервером в RTK Query. После этого Echo в
основной реализации станет функционально полным.
1. Очередь курьеров: middleware
Reducer обязан быть чистым: ему нельзя ходить в сеть, ставить таймеры, читать
время. Но приложению всё это нужно. Куда же девать грязную работу? Между
dispatch и reducer Redux ставит очередь курьеров. Каждый
middleware
смотрит на проезжающий мимо action и решает: пропустить дальше, изменить,
отменить или запустить в ответ что-то своё. Подпись курьера всегда одна:
const logger = (store) => (next) => (action) => {
console.log('action:', action.type);
return next(action); // пропустить дальше по цепочке
};
Три вложенные функции это и есть store => next => action. next это следующий
курьер в очереди (или сам reducer, если курьер последний). Не позвал next,
action не доедет до reducer. Этот же приём ты уже видел в 06-node:
серверные middleware Express и Fastify устроены так же, цепочка обработчиков,
каждый зовёт следующего.
Чаще всего свой middleware писать не приходится: RTK даёт два готовых способа делать сайд-эффекты. Первый, для одиночного запроса, это createAsyncThunk:
export const loadProfile = createAsyncThunk('auth/loadProfile', async (userId: string) => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('profile failed');
return (await response.json()) as User;
});
// в slice:
extraReducers: (builder) => {
builder
.addCase(loadProfile.pending, (state) => {
state.status = 'loading';
})
.addCase(loadProfile.fulfilled, (state, action) => {
state.status = 'ready';
state.user = action.payload;
})
.addCase(loadProfile.rejected, (state) => {
state.status = 'error';
});
},
createAsyncThunk сам диспатчит pending, fulfilled и rejected, а слайс
ловит их в extraReducers. Это тот самый process state из урока 09, только
сгенерированный за тебя.
Второй способ, для реакций на чужие actions, это listenerMiddleware. Он слушает поток actions и реагирует, это штатная замена redux-saga:
listenerMiddleware.startListening({
actionCreator: themeToggled,
effect: (action, listenerApi) => {
localStorage.setItem('theme', listenerApi.getState().ui.theme);
},
});
Saga на генераторах (takeLatest, takeEvery, select) тоже существует и иногда
оправдана для сложных гонок, но для девяноста процентов задач хватает listener.
Что взять с собой
- Middleware это
store => next => action: курьер междуdispatchи reducer. - Грязная работа живёт в middleware, поэтому reducer остаётся чистым.
createAsyncThunkдля одиночного запроса (pending/fulfilled/rejected).listenerMiddlewareдля реакций на actions, штатная замена redux-saga.
2. Server state это кэш, а не store
Можно было бы грузить сообщения через createAsyncThunk и складывать в слайс. Но
вспомни, чем server state отличается от обычного: это копия серверных данных,
которая устаревает сама. Класть её в обычный store значит руками писать всё, что
делает кэш:
- не запрашивать одно и то же дважды (дедупликация);
- помнить, когда данные протухли, и перезапрашивать;
- перезапрашивать при возврате на вкладку и восстановлении сети;
- показывать загрузку и ошибку для каждого запроса;
- оптимистично обновлять и откатывать при провале.
Это много кода, и он одинаковый в каждом приложении. Поэтому server state отдают специальному инструменту. В экосистеме Redux это RTK Query. Вне Redux ту же роль играют TanStack Query (React Query) и SWR. Идея у всех одна: ты не пишешь руками синхронизацию, ты декларируешь, откуда данные и когда они протухают.
Что взять с собой
- Server state это устаревающая копия, ему нужен кэш, а не обычный store.
- Кэш закрывает дедупликацию, инвалидацию, refetch, загрузку/ошибку, оптимизм.
- RTK Query это кэш для Redux, аналоги это React Query и SWR.
3. createApi и сгенерированные хуки
RTK Query описывают одним вызовом createApi: базовый запрос, типы тегов и
эндпоинты. Эндпоинт бывает двух видов: query (читает) и mutation (меняет).
// apps/echo-react-rtk/src/store/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { type Channel, type Message } from '../data/types';
export const echoApi = createApi({
reducerPath: 'echoApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Channel', 'Message', 'User'],
endpoints: (build) => ({
getChannels: build.query<Channel[], void>({
query: () => '/channels',
}),
getMessages: build.query<Message[], string>({
query: (channelId) => `/channels/${channelId}/messages`,
}),
sendMessage: build.mutation<Message, { channelId: string; text: string }>({
query: ({ channelId, text }) => ({
url: `/channels/${channelId}/messages`,
method: 'POST',
body: { text },
}),
}),
}),
});
export const { useGetChannelsQuery, useGetMessagesQuery, useSendMessageMutation } = echoApi;
Из описания RTK Query сам сгенерировал хуки. Подключить его в store это две строки: добавить reducer и его middleware.
configureStore({
reducer: {
ui: uiReducer,
[echoApi.reducerPath]: echoApi.reducer,
},
middleware: (getDefault) => getDefault().concat(echoApi.middleware),
});
Теперь компонент не знает ни про fetch, ни про слайсы загрузки. Он зовёт хук и
получает данные плюс статус:
export function MessageList({ channelId }: { channelId: string }) {
const { data: messages = [], isLoading, isError } = useGetMessagesQuery(channelId);
if (isLoading) return <MessageSkeleton />;
if (isError) return <ErrorBanner />;
return (
<ol>
{messages.map((message) => (
<li key={message.id}>{message.text}</li>
))}
</ol>
);
}
Два компонента, вызвавшие useGetMessagesQuery('general'), не сделают два
запроса: RTK Query дедуплицирует их и отдаст обоим один кэш. Включив на эндпоинте
refetchOnFocus и refetchOnReconnect (плюс setupListeners(store.dispatch)),
ты получишь автоматический перезапрос при возврате на вкладку.
Что взять с собой
createApiописывает эндпоинты:queryчитает,mutationменяет.- Из описания генерируются хуки
useGetXxxQueryиuseXxxMutation. - Подключение в store это reducer плюс
.concat(api.middleware). - Одинаковые запросы дедуплицируются,
refetchOnFocusперезапрашивает по фокусу.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.