Постепенный переход на RTK Query

Многие проекты на React и Redux формировались задолго до появления RTK Query. Внутри таких приложений обычно уже существуют:

  • Redux Thunk;
  • Redux Saga;
  • Axios-сервисы;
  • кастомные middleware;
  • собственные слои кэширования;
  • ручная обработка загрузок и ошибок;
  • сложная архитектура асинхронных запросов.

Полная одномоментная миграция практически всегда несёт риски:

  • нарушение стабильности приложения;
  • регрессии;
  • конфликт старой и новой логики;
  • переписывание большого количества компонентов;
  • разрушение существующего API-слоя.

Поэтому RTK Query обычно внедряется постепенно, по модулям и функциональным областям.


Архитектура постепенной миграции

Наиболее безопасная стратегия выглядит следующим образом:

  1. RTK Query добавляется в существующий Redux Store.
  2. Старые thunk/saga продолжают работать.
  3. Новые экраны создаются уже на RTK Query.
  4. Старые модули постепенно переводятся.
  5. Дублирующая логика удаляется после стабилизации.

Такой подход позволяет:

  • не останавливать разработку;
  • не переписывать приложение целиком;
  • уменьшить риск ошибок;
  • внедрять RTK Query по частям.

Добавление RTK Query в существующий проект

Установка зависимостей

npm install @reduxjs/toolkit react-redux

Интеграция API Slice в существующий Store

Допустим, проект уже использует Redux.

Старый store:

import { configureStore } from '@reduxjs/toolkit'
import authReducer from './authSlice'
import postsReducer from './postsSlice'

export const store = configureStore({
    reducer: {
        auth: authReducer,
        posts: postsReducer,
    },
})

Добавление RTK Query:

import { configureStore } from '@reduxjs/toolkit'
import { api } from './services/api'

import authReducer from './authSlice'
import postsReducer from './postsSlice'

export const store = configureStore({
    reducer: {
        auth: authReducer,
        posts: postsReducer,

        [api.reducerPath]: api.reducer,
    },

    middleware: (getDefaultMiddleware) =>
        getDefaultMiddleware().concat(api.middleware),
})

Старые reducer продолжают работать без изменений.


Создание первого API Slice

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const api = createApi({
    reducerPath: 'api',

    baseQuery: fetchBaseQuery({
        baseUrl: 'https://jsonplaceholder.typicode.com',
    }),

    endpoints: (builder) => ({
        getPosts: builder.query({
            query: () => '/posts',
        }),
    }),
})

export const {
    useGetPostsQuery,
} = api

Сосуществование RTK Query и Redux Thunk

Во время миграции обе технологии могут использоваться одновременно.

Старый thunk

import axios from 'axios'

export const fetchPosts = () => async (dispatch) => {
    dispatch({ type: 'posts/loading' })

    try {
        const response = await axios.get('/api/posts')

        dispatch({
            type: 'posts/success',
            payload: response.data,
        })
    } catch (error) {
        dispatch({
            type: 'posts/error',
            payload: error.message,
        })
    }
}

Новый RTK Query endpoint

getPosts: builder.query({
    query: () => '/posts',
})

Оба механизма могут существовать параллельно до полного удаления thunk.


Постепенная миграция экранов

Обычно миграция выполняется по экранам или функциональным модулям.

Пример безопасного порядка:

Этап Что переносится
1 Read-only страницы
2 Списки
3 Детальные страницы
4 Формы редактирования
5 Сложные mutation
6 Legacy middleware

Почему сначала переносят Query

Query значительно проще mutation.

Причины:

  • нет изменения данных;
  • нет optimistic update;
  • нет rollback;
  • меньше побочных эффектов;
  • проще тестирование.

Поэтому миграция обычно начинается с GET-запросов.


Замена useEffect + dispatch

Старый подход

import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { fetchPosts } from './postsThunk'

export function PostsPage() {
    const dispatch = useDispatch()

    const posts = useSelector((state) => state.posts.items)
    const loading = useSelector((state) => state.posts.loading)

    useEffect(() => {
        dispatch(fetchPosts())
    }, [dispatch])

    if (loading) {
        return <div>Loading...</div>
    }

    return (
        <div>
            {posts.map((post) => (
                <div key={post.id}>
                    {post.title}
                </div>
            ))}
        </div>
    )
}

Новый подход

import { useGetPostsQuery } from './services/api'

export function PostsPage() {
    const {
        data: posts,
        isLoading,
    } = useGetPostsQuery()

    if (isLoading) {
        return <div>Loading...</div>
    }

    return (
        <div>
            {posts.map((post) => (
                <div key={post.id}>
                    {post.title}
                </div>
            ))}
        </div>
    )
}

Количество кода сокращается в несколько раз.


Миграция Axios-сервисов

Во многих проектах существует отдельный слой API:

import axios from 'axios'

export const postsApi = {
    async getPosts() {
        const response = await axios.get('/api/posts')
        return response.data
    },

    async createPost(data) {
        const response = await axios.post('/api/posts', data)
        return response.data
    },
}

RTK Query может использовать этот слой без переписывания backend-клиента.


Использование queryFn

import { createApi } from '@reduxjs/toolkit/query/react'
import { postsApi } from './postsApi'

export const api = createApi({
    reducerPath: 'api',

    baseQuery: async () => ({ data: null }),

    endpoints: (builder) => ({
        getPosts: builder.query({
            async queryFn() {
                try {
                    const data = await postsApi.getPosts()

                    return { data }
                } catch (error) {
                    return {
                        error: {
                            status: 500,
                            data: error.message,
                        },
                    }
                }
            },
        }),
    }),
})

Это позволяет:

  • сохранить Axios;
  • сохранить interceptors;
  • не переписывать инфраструктуру;
  • постепенно переносить API.

Миграция Redux Saga

Redux Saga часто используется в старых enterprise-приложениях.

Типичный saga-запрос:

function* fetchPostsSaga() {
    try {
        const response = yield call(api.getPosts)

        yield put({
            type: 'posts/success',
            payload: response.data,
        })
    } catch (error) {
        yield put({
            type: 'posts/error',
            payload: error.message,
        })
    }
}

После миграции логика запроса исчезает:

getPosts: builder.query({
    query: () => '/posts',
})

Saga остаётся только для сложной бизнес-логики:

  • websocket;
  • orchestration;
  • background sync;
  • polling нескольких систем;
  • сложные workflow.

Частичная замена Saga

RTK Query не обязан полностью заменять Saga.

Очень распространённая архитектура:

Задача Технология
HTTP cache RTK Query
Fetch данных RTK Query
CRUD RTK Query
Websocket orchestration Saga
Сложные процессы Saga

Удаление лишних reducer

После переноса endpoint старые reducer становятся ненужными.

До миграции

const initialState = {
    items: [],
    loading: false,
    error: null,
}

После миграции

RTK Query уже хранит:

  • data;
  • loading;
  • fetching;
  • error;
  • timestamps;
  • cache lifecycle.

Поэтому reducer можно удалить полностью.


Удаление action types

До RTK Query:

POSTS_REQUEST
POSTS_SUCCESS
POSTS_ERROR

После RTK Query:

  • ручные action не нужны;
  • lifecycle генерируется автоматически.

Постепенное удаление селекторов

Старый код:

export const selectPosts = (state) => state.posts.items

Новый код:

const { data } = useGetPostsQuery()

Либо:

api.endpoints.getPosts.select()

Совместное использование старого state и RTK Query

Во время миграции часть состояния остаётся в Redux slices.

Пример:

{
    auth,
    settings,
    ui,
    api,
}

RTK Query отвечает только за серверное состояние.

UI-state продолжает жить отдельно.


Что не нужно переносить в RTK Query

RTK Query предназначен для server-state.

Не рекомендуется хранить там:

  • модальные окна;
  • theme;
  • dropdown state;
  • form visibility;
  • локальные UI-флаги;
  • drag-and-drop состояние.

Стратегия миграции mutation

Mutation сложнее query.

Особенно если используются:

  • optimistic updates;
  • rollback;
  • сложная синхронизация;
  • цепочки запросов.

Безопасная миграция mutation

Первый этап

Только query.


Второй этап

Простые mutation:

createPost
deletePost
updatePost

Третий этап

Optimistic upd ate.


Четвёртый этап

Сложные workflow.


Перенос POST-запросов

Старый thunk

export const createPost = (data) => async (dispatch) => {
    dispatch({ type: 'create/loading' })

    try {
        const response = await axios.post('/posts', data)

        dispatch({
            type: 'create/success',
            payload: response.data,
        })
    } catch (error) {
        dispatch({
            type: 'create/error',
            payload: error.message,
        })
    }
}

Новый mutation endpoint

createPost: builder.mutation({
    query: (body) => ({
        url: '/posts',
        method: 'POST',
        body,
    }),
})

Замена ручного refetch

Старый подход:

dispatch(fetchPosts())

После создания записи.


RTK Query:

providesTags: ['Posts'],
invalidatesTags: ['Posts'],

Автоматическое обновление заменяет ручной refetch.


Миграция кэширования

Старые приложения часто имеют:

  • memoization;
  • normalize layer;
  • entity cache;
  • request deduplication.

RTK Query уже содержит:

  • автоматический cache;
  • deduplication;
  • request lifecycle;
  • invalidation;
  • polling;
  • refetching.

Многие legacy-механизмы становятся ненужными.


Проблема двойного кэша

Во время миграции может возникнуть ситуация:

  • старый Redux cache;
  • RTK Query cache.

Это опасно рассинхронизацией.


Как избегать двойного кэша

После миграции конкретного endpoint:

  1. Удаляется старый reducer.
  2. Удаляются selectors.
  3. Удаляется thunk/saga.
  4. RTK Query становится единственным источником данных.

Feature-based миграция

Наиболее эффективный подход — перенос по feature-модулям.

Пример:

features/
    users/
    posts/
    comments/

Каждый модуль мигрируется независимо.


Перенос legacy API layer

Иногда API слой огромен:

api/
    usersApi.js
    postsApi.js
    authApi.js
    commentsApi.js

Необязательно переписывать всё.

RTK Query может постепенно оборачивать существующие сервисы.


Интеграция с существующей авторизацией

Большинство старых проектов уже имеют:

  • refresh token;
  • interceptors;
  • retry logic;
  • auth middleware.

RTK Query не требует переписывания auth-системы.


Использование custom baseQuery

const baseQuery = fetchBaseQuery({
    baseUrl: '/api',

    prepareHeaders: (headers, { getState }) => {
        const token = getState().auth.token

        if (token) {
            headers.se t('authorization', `Bearer ${token}`)
        }

        return headers
    },
})

Сохранение Axios interceptors

const axiosBaseQuery =
    ({ baseUrl }) =>
    async ({ url, method, data, params }) => {
        try {
            const result = await axios({
                url: baseUrl + url,
                method,
                data,
                params,
            })

            return { data: result.data }
        } catch (axiosError) {
            return {
                error: {
                    status: axiosError.response?.status,
                    data: axiosError.response?.data,
                },
            }
        }
    }

Использование Axios внутри RTK Query

export const api = createApi({
    reducerPath: 'api',

    baseQuery: axiosBaseQuery({
        baseUrl: '/api',
    }),

    endpoints: (builder) => ({
        getPosts: builder.query({
            query: () => ({
                url: '/posts',
                method: 'GET',
            }),
        }),
    }),
})

Это особенно полезно при миграции больших enterprise-проектов.


Миграция TypeScript-проектов

В старых проектах типизация часто строится вручную.

RTK Query уменьшает объём типов.


Старый подход

interface PostsState {
    items: Post[]
    loading: boolean
    error: string | null
}

RTK Query

getPosts: builder.query<Post[], void>({
    query: () => '/posts',
})

Постепенная очистка архитектуры

После миграции обычно исчезают:

  • async reducers;
  • request actions;
  • loading reducers;
  • error reducers;
  • ручной cache;
  • request middleware;
  • custom fetch hooks.

Типичные ошибки миграции

Полный rewrite

Самая опасная стратегия.


Одновременная миграция query и mutation

Слишком высокий риск.


Удаление старого API раньше времени

Может вызвать регрессии.


Смешивание источников истины

Нельзя одновременно использовать:

  • старый posts reducer;
  • RTK Query cache.

Пошаговый безопасный сценарий миграции

Этап 1

Добавление RTK Query в Store.


Этап 2

Создание первого API Slice.


Этап 3

Перенос read-only запросов.


Этап 4

Удаление legacy reducer.


Этап 5

Перенос mutation.


Этап 6

Перенос invalidation.


Этап 7

Удаление thunk/saga.


Этап 8

Очистка архитектуры.


Признаки успешной миграции

После внедрения RTK Query обычно наблюдаются:

  • значительное сокращение boilerplate;
  • уменьшение количества action types;
  • исчезновение ручного loading-state;
  • упрощение компонентов;
  • уменьшение количества useEffect;
  • автоматизация cache invalidation;
  • снижение количества race conditions;
  • уменьшение количества дублирующихся запросов;
  • более предсказуемое управление server-state.