Зависимые запросы

Зависимые запросы — это механизм, при котором выполнение одного HTTP-запроса зависит от результата другого. В RTK Query подобная схема используется очень часто:

  • загрузка профиля после авторизации;
  • получение списка комментариев после загрузки статьи;
  • запрос детальной информации после получения идентификаторов;
  • цепочки связанных API-вызовов;
  • условная загрузка данных.

RTK Query предоставляет несколько способов организации зависимых запросов:

  • skip
  • skipToken
  • условные параметры
  • ручной запуск через lazy hooks
  • использование данных одного endpoint внутри другого
  • orchestration через queryFn
  • последовательные мутации и запросы

Базовая схема зависимого запроса

Наиболее распространённый сценарий:

  1. Выполняется первый запрос.
  2. Из ответа извлекаются данные.
  3. Второй запрос стартует только после получения необходимых значений.

Пример:

const { data: user } = useGetUserQuery(userId);

const { data: posts } = useGetPostsByUserQuery(user?.id, {
    skip: !user?.id
});

Логика работы:

  • useGetUserQuery загружает пользователя;
  • пока user.id отсутствует — второй запрос не выполняется;
  • после получения user.id автоматически запускается useGetPostsByUserQuery.

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

skip — основной механизм условного отключения запроса.

Синтаксис

useSomeQuery(arg, {
    skip: boolean
});

Если skip === true:

  • запрос не выполняется;
  • endpoint не создаёт network request;
  • состояние остаётся uninitialized.

Пример с профилем и заказами

export const api = createApi({
    reducerPath: 'api',
    baseQuery: fetchBaseQuery({
        baseUrl: '/api'
    }),
    endpoints: (builder) => ({
        getProfile: builder.query({
            query: () => '/profile'
        }),

        getOrders: builder.query({
            query: (userId) => `/users/${userId}/orders`
        })
    })
});

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

function OrdersPage() {
    const { data: profile } = useGetProfileQuery();

    const {
        data: orders,
        isLoading
    } = useGetOrdersQuery(profile?.id, {
        skip: !profile?.id
    });

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

    return (
        <div>
            {orders?.map(order => (
                <div key={order.id}>
                    {order.title}
                </div>
            ))}
        </div>
    );
}

Что происходит внутри RTK Query

При первом рендере:

profile = undefined

Следовательно:

skip: true

Запрос getOrders не стартует.

После завершения getProfile:

profile = {
    id: 15
}

Теперь:

skip: false

RTK Query автоматически выполняет:

GET /users/15/orders

skip и состояние uninitialized

Если запрос пропущен:

const result = useGetOrdersQuery(userId, {
    skip: true
});

Состояние:

{
    isUninitialized: true,
    isLoading: false,
    isFetching: false,
    data: undefined
}

Это важно при построении UI.

Пример:

if (result.isUninitialized) {
    return <div>Ожидание userId</div>;
}

Проблема undefined аргументов

Частая ошибка:

useGetUserQuery(undefined);

Некоторые API могут интерпретировать это как:

/users/undefined

Правильный подход:

useGetUserQuery(userId, {
    skip: !userId
});

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

RTK Query предоставляет специальный объект:

skipToken

Он полностью отключает запрос без использования skip.


Пример skipToken

import { skipToken } from '@reduxjs/toolkit/query';

const { data } = useGetUserQuery(
    userId ?? skipToken
);

Если userId === null или undefined:

skipToken

запрос не выполняется.


Преимущества skipToken

Более чистый код

Вместо:

useGetUserQuery(userId, {
    skip: !userId
});

используется:

useGetUserQuery(userId ?? skipToken);

Лучшая типизация

Особенно полезно в TypeScript.

Без skipToken:

useGetUserQuery(userId as number)

Со skipToken:

useGetUserQuery(userId ?? skipToken)

Типы становятся безопаснее.


Каскадные зависимые запросы

RTK Query позволяет строить длинные цепочки.

Пример

const { data: session } = useGetSessionQuery();

const { data: profile } = useGetProfileQuery(
    session?.userId,
    {
        skip: !session?.userId
    }
);

const { data: permissions } = useGetPermissionsQuery(
    profile?.roleId,
    {
        skip: !profile?.roleId
    }
);

Последовательность:

Session -> Profile -> Permissions

Проблемы каскадных запросов

Слишком длинные цепочки создают:

  • множественные ререндеры;
  • waterfall requests;
  • задержки UI;
  • сложность поддержки.

Waterfall Requests

Waterfall — последовательные сетевые запросы.

Плохой пример:

1 секунда -> user
1 секунда -> posts
1 секунда -> comments

Итог:

3 секунды

Хотя запросы могли бы выполняться параллельно.


Когда зависимые запросы необходимы

Зависимость оправдана, если:

  • второй запрос требует ID из первого;
  • нужен access token;
  • необходимо вычислить параметры;
  • сервер возвращает динамические endpoint;
  • запросы невозможно выполнить параллельно.

Когда лучше избегать зависимостей

Нежелательно:

user -> posts -> comments -> reactions -> likes

Лучше:

  • объединить endpoints;
  • использовать batch API;
  • загружать данные параллельно;
  • изменять backend-контракт.

Lazy Queries

RTK Query поддерживает ручной запуск запросов.

Используется hook:

useLazyQuery

Пример lazy query

const [fetchUser, result] = useLazyGetUserQuery();

Запуск:

await fetchUser(15);

Зависимость через lazy query

function Page() {
    const [fetchUser] = useLazyGetUserQuery();
    const [fetchPosts] = useLazyGetPostsQuery();

    const load = async () => {
        const userResult = await fetchUser(1);

        if (userResult.data) {
            await fetchPosts(userResult.data.id);
        }
    };

    useEffect(() => {
        load();
    }, []);

    return <div>Page</div>;
}

Преимущества lazy queries

Полный контроль

Можно:

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

Асинхронные сценарии

Например:

login
↓
получение токена
↓
получение профиля
↓
получение настроек
↓
redirect

Обработка ошибок в зависимых запросах

Пример

const load = async () => {
    const userResult = await fetchUser(1);

    if (userResult.error) {
        return;
    }

    const postsResult = await fetchPosts(
        userResult.data.id
    );

    if (postsResult.error) {
        console.error(postsResult.error);
    }
};

unwrap и зависимые запросы

RTK Query поддерживает:

.unwrap()

Это позволяет использовать try/catch.


Пример

const load = async () => {
    try {
        const user = await fetchUser(1).unwrap();

        const posts = await fetchPosts(user.id).unwrap();

        console.log(posts);
    } catch (error) {
        console.error(error);
    }
};

queryFn для сложных зависимостей

queryFn позволяет объединить несколько запросов в одном endpoint.


Пример queryFn

getUserWithPosts: builder.query({
    async queryFn(userId, _api, _extraOptions, baseQuery) {
        const userResult = await baseQuery(
            `/users/${userId}`
        );

        if (userResult.error) {
            return {
                error: userResult.error
            };
        }

        const postsResult = await baseQuery(
            `/users/${userId}/posts`
        );

        if (postsResult.error) {
            return {
                error: postsResult.error
            };
        }

        return {
            data: {
                user: userResult.data,
                posts: postsResult.data
            }
        };
    }
})

Преимущества queryFn

Инкапсуляция логики

Компонент получает:

const { data } = useGetUserWithPostsQuery(1);

вместо:

user query
↓
posts query
↓
conditions
↓
loading states

Централизация ошибок

Вся логика ошибок хранится внутри endpoint.


Единое состояние загрузки

isLoading
isError
data

относятся сразу ко всей цепочке.


Параллельные запросы внутри queryFn

Можно комбинировать последовательные и параллельные операции.


Пример

async queryFn(userId, _api, _extraOptions, baseQuery) {
    const userResult = await baseQuery(
        `/users/${userId}`
    );

    if (userResult.error) {
        return {
            error: userResult.error
        };
    }

    const [postsResult, commentsResult] =
        await Promise.all([
            baseQuery(`/users/${userId}/posts`),
            baseQuery(`/users/${userId}/comments`)
        ]);

    return {
        data: {
            user: userResult.data,
            posts: postsResult.data,
            comments: commentsResult.data
        }
    };
}

Зависимые мутации

Мутации также могут зависеть друг от друга.


Пример последовательных мутаций

const [login] = useLoginMutation();
const [loadProfile] = useLazyGetProfileQuery();

const handleLogin = async () => {
    try {
        const auth = await login({
            email,
            password
        }).unwrap();

        await loadProfile(auth.userId).unwrap();
    } catch (error) {
        console.error(error);
    }
};

Авторизация как зависимый сценарий

Типичная схема:

login
↓
save token
↓
get profile
↓
get permissions
↓
open application

RTK Query хорошо подходит для подобных flow.


Условная зависимость

Иногда второй запрос зависит не только от наличия данных, но и от их содержимого.


Пример

const { data: user } = useGetUserQuery(id);

const { data: premium } =
    useGetPremiumDataQuery(user?.id, {
        skip: user?.role !== 'premium'
    });

Запрос выполняется только для premium-пользователей.


Зависимые polling-запросы

Polling тоже можно комбинировать с зависимостями.


Пример

const { data: task } = useGetTaskQuery(taskId);

const { data: progress } =
    useGetProgressQuery(task?.id, {
        skip: !task?.id,
        pollingInterval: 3000
    });

Переключение параметров

RTK Query автоматически отслеживает изменения аргументов.


Пример

const { data: user } = useGetUserQuery(id);

const { data: posts } =
    useGetPostsQuery(user?.id, {
        skip: !user?.id
    });

Если user.id изменится:

старый cache key удаляется
↓
создаётся новый запрос
↓
данные обновляются

Кэширование зависимых запросов

Каждый endpoint кэшируется отдельно.


Пример

useGetUserQuery(1)
useGetPostsQuery(1)

создают два независимых cache entry.

Изменение пользователя не инвалидирует posts автоматически.


Инвалидация связанных данных

Для синхронизации используются теги.


Пример

getPosts: builder.query({
    query: (userId) => `/users/${userId}/posts`,
    providesTags: ['Posts']
}),

updatePost: builder.mutation({
    query: (post) => ({
        url: `/posts/${post.id}`,
        method: 'PUT',
        body: post
    }),
    invalidatesTags: ['Posts']
})

Зависимые запросы и SSR

При server-side rendering важно:

  • соблюдать последовательность;
  • дожидаться завершения запросов;
  • избегать condition mismatch.

Пример проблемы

На сервере:

skip: true

На клиенте:

skip: false

Это может привести к hydration mismatch.


Предзагрузка зависимых данных

RTK Query поддерживает prefetch.


Пример

dispatch(
    api.util.prefetch(
        'getPosts',
        userId,
        {
            force: true
        }
    )
);

Комбинация prefetch и зависимостей

Пример:

hover user
↓
prefetch profile
↓
open page
↓
profile already cached
↓
dependent queries start instantly

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

Позволяет уменьшать ререндеры.


Пример

const { userId } = useGetSessionQuery(undefined, {
    selectFromResult: ({ data }) => ({
        userId: data?.userId
    })
});

Далее:

const { data: profile } =
    useGetProfileQuery(userId, {
        skip: !userId
    });

Оптимизация зависимых запросов

Минимизировать waterfall

Лучше:

user
↓
parallel:
posts
comments
settings

чем:

user
↓
posts
↓
comments
↓
settings

Объединять endpoints

Иногда эффективнее:

GET /dashboard

вместо:

/users
/posts
/comments
/stats

Использовать queryFn для orchestration

Сложные цепочки лучше скрывать внутри API-слоя.


Антипаттерны

Запросы внутри useEffect без необходимости

Плохо:

useEffect(() => {
    fetchUser(id);
}, [id]);

Если можно использовать обычный query hook.


Избыточные зависимости

Плохо:

A -> B -> C -> D -> E

Дублирование логики skip

Плохо:

if (!user) return null;

const query = useGetPostsQuery(user.id, {
    skip: !user
});

Достаточно одного механизма.


Практическая архитектура зависимых запросов

Наиболее стабильная схема:

UI-уровень

Только простые зависимости:

skip
skipToken

API-уровень

Сложная orchestration:

queryFn

Бизнес-логика

Асинхронные flow:

lazy queries
mutations
unwrap

Полный пример сложного сценария

const [login] = useLoginMutation();
const [loadProfile] =
    useLazyGetProfileQuery();

const [loadPermissions] =
    useLazyGetPermissionsQuery();

const handleAuth = async () => {
    try {
        const auth = await login({
            email,
            password
        }).unwrap();

        const profile = await loadProfile(
            auth.userId
        ).unwrap();

        const permissions =
            await loadPermissions(
                profile.roleId
            ).unwrap();

        console.log(
            auth,
            profile,
            permissions
        );
    } catch (error) {
        console.error(error);
    }
};

Сравнение подходов

skip

Подходит для:

  • простых зависимостей;
  • UI-условий;
  • одного уровня вложенности.

skipToken

Подходит для:

  • nullable параметров;
  • TypeScript;
  • компактного кода.

lazy queries

Подходят для:

  • ручного управления;
  • сложных flow;
  • последовательных операций.

queryFn

Подходит для:

  • orchestration;
  • объединения запросов;
  • скрытия сложности;
  • агрегирования данных.