Overview
RTK Query custom base queries let a React application control how requests are executed while still using RTK Query for generated hooks, caching, loading states, mutation states, invalidation, polling, and request lifecycle behavior.
In most RTK Query APIs, endpoints define a query value and RTK Query passes that value to a shared baseQuery. The default choice is usually fetchBaseQuery, a lightweight wrapper around fetch. When an app needs different transport behavior, such as Axios, GraphQL, automatic reauthorization, custom error normalization, dynamic base URLs, or retry policies, it can wrap fetchBaseQuery or provide a fully custom baseQuery.
This topic matters in production React apps because the API layer is where authentication, cancellation, error shape, cache behavior, and feature boundaries meet. A weak implementation can break caching, duplicate refresh calls, leak auth headers, or leave query hooks stuck in the wrong state.
For interviews, this topic tests whether a candidate understands RTK Query's core contract: a base query is not just a request helper. It is the adapter between the transport layer and RTK Query's cache and hook state machine.
Core Concepts
The Role of baseQuery
baseQuery is the shared request executor used by an RTK Query API slice.
Endpoint definitions describe what to request:
getUser: build.query<User, string>({
query: (userId) => `/users/${userId}`,
});
The baseQuery decides how to execute that request:
export const api = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
endpoints: (build) => ({
getUser: build.query<User, string>({
query: (userId) => `/users/${userId}`,
}),
}),
});
This split is important. Endpoint code should stay focused on feature intent. The base query handles shared transport behavior such as base URL, headers, response parsing, auth, timeout, and error conversion.
The Required Return Shape
RTK Query base queries and queryFn functions must return one of these shapes:
return { data: value };
or:
return { error: errorValue };
They should not let transport errors escape as unhandled thrown exceptions.
Bad:
const brokenBaseQuery = async () => {
const response = await fetch("/api/users");
const data = await response.json();
return { data };
};
Better:
const safeBaseQuery = async () => {
try {
const response = await fetch("/api/users");
if (!response.ok) {
return {
error: {
status: response.status,
data: await response.json().catch(() => null),
},
};
}
return { data: await response.json() };
} catch (error) {
return {
error: {
status: "CUSTOM_ERROR",
error: error instanceof Error ? error.message : "Unknown error",
},
};
}
};
This contract allows RTK Query to track isLoading, isError, cached errors, retries, and hook state correctly.
fetchBaseQuery
fetchBaseQuery is RTK Query's built-in base query for HTTP APIs. It is intentionally small and similar to a lightweight fetch wrapper.
Common features:
baseUrlfor relative endpoint paths.prepareHeadersfor common headers.- Automatic JSON body handling.
- Response parsing.
- Error objects with status and data.
- Optional timeout.
- Access to
signal,dispatch, andgetState. - Support for custom parameter serialization.
Example:
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { RootState } from "../store";
export const api = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({
baseUrl: "/api",
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.accessToken;
if (token) {
headers.set("authorization", `Bearer ${token}`);
}
return headers;
},
}),
endpoints: () => ({}),
});
prepareHeaders is a good place for common auth headers because it runs at request time and can read current Redux state.
When fetchBaseQuery Is Enough
Use fetchBaseQuery when the app needs normal REST-style HTTP behavior:
- JSON requests and responses.
- Basic auth header preparation.
- Relative endpoint paths.
- Standard status-based error handling.
- Simple timeout behavior.
- RTK Query cache and invalidation features.
Example endpoint:
type Product = {
id: string;
name: string;
};
export const productsApi = api.injectEndpoints({
endpoints: (build) => ({
getProduct: build.query<Product, string>({
query: (id) => `/products/${id}`,
providesTags: (_result, _error, id) => [{ type: "Product", id }],
}),
}),
});
Do not create a custom base query only because it feels more architectural. A custom base query is useful when it solves a real transport or cross-cutting behavior problem.
Why Create a Custom Base Query
Custom base queries are useful when the app needs:
- Axios instead of
fetch. - GraphQL request handling.
- A third-party SDK.
- A non-HTTP async source.
- Automatic token refresh.
- A shared retry policy.
- Dynamic base URLs from Redux state.
- Custom request metadata.
- Custom error normalization.
- Special response parsing.
- Cross-service routing.
The main rule: keep the base query generic. Endpoint-specific logic usually belongs in endpoint definitions, transformResponse, transformErrorResponse, or queryFn.
Axios-Based baseQuery
An Axios-based base query lets an RTK Query API use Axios for transport while preserving RTK Query's { data } / { error } contract.
Example:
import type { BaseQueryFn } from "@reduxjs/toolkit/query";
import axios from "axios";
import type { AxiosError, AxiosRequestConfig } from "axios";
type AxiosBaseQueryArgs = {
url: string;
method?: AxiosRequestConfig["method"];
data?: AxiosRequestConfig["data"];
params?: AxiosRequestConfig["params"];
headers?: AxiosRequestConfig["headers"];
};
type AxiosBaseQueryError = {
status?: number;
data: unknown;
};
export const axiosBaseQuery =
(
{ baseUrl }: { baseUrl: string } = { baseUrl: "" },
): BaseQueryFn<AxiosBaseQueryArgs, unknown, AxiosBaseQueryError> =>
async ({ url, method = "GET", data, params, headers }, { signal }) => {
try {
const result = await axios({
url: baseUrl + url,
method,
data,
params,
headers,
signal,
});
return { data: result.data };
} catch (axiosError) {
const error = axiosError as AxiosError;
return {
error: {
status: error.response?.status,
data: error.response?.data ?? error.message,
},
};
}
};
Then use it in createApi:
export const api = createApi({
reducerPath: "api",
baseQuery: axiosBaseQuery({ baseUrl: "/api" }),
tagTypes: ["User"],
endpoints: (build) => ({
getUser: build.query<User, string>({
query: (id) => ({ url: `/users/${id}` }),
providesTags: (_result, _error, id) => [{ type: "User", id }],
}),
updateUser: build.mutation<User, Partial<User> & Pick<User, "id">>({
query: ({ id, ...patch }) => ({
url: `/users/${id}`,
method: "PATCH",
data: patch,
}),
invalidatesTags: (_result, _error, { id }) => [{ type: "User", id }],
}),
}),
});
The important detail is that Axios errors are caught and converted into the RTK Query error shape.
Using a Configured Axios Instance
In production, teams often use a configured Axios instance instead of calling axios directly.
Example:
export const http = axios.create({
baseURL: "/api",
timeout: 10_000,
headers: {
Accept: "application/json",
},
});
Base query:
export const axiosInstanceBaseQuery =
(): BaseQueryFn<AxiosBaseQueryArgs, unknown, AxiosBaseQueryError> =>
async ({ url, method = "GET", data, params, headers }, { signal }) => {
try {
const result = await http.request({
url,
method,
data,
params,
headers,
signal,
});
return { data: result.data };
} catch (axiosError) {
const error = axiosError as AxiosError;
return {
error: {
status: error.response?.status,
data: error.response?.data ?? error.message,
},
};
}
};
This allows the app to reuse Axios timeouts, interceptors, base URL, and other defaults. The trade-off is that interceptor behavior becomes part of the RTK Query transport boundary and must be easy to reason about.
Cancellation and signal
RTK Query passes an AbortSignal to baseQuery. A good custom base query forwards that signal to the underlying transport.
With fetchBaseQuery, this is handled internally. With Axios, pass signal:
const result = await http.request({
url,
method,
data,
params,
signal,
});
This matters when:
- A component unmounts.
- Query arguments change.
- A request is no longer needed.
- RTK Query cancels an in-flight subscription.
Ignoring cancellation can waste network resources and allow stale responses to create confusing behavior.
Auth Headers with prepareHeaders
For fetchBaseQuery, use prepareHeaders to attach common headers:
const rawBaseQuery = fetchBaseQuery({
baseUrl: "/api",
prepareHeaders: (headers, { getState }) => {
const token = selectAccessToken(getState() as RootState);
if (token) {
headers.set("authorization", `Bearer ${token}`);
}
return headers;
},
});
For Axios-based base queries, there are two common approaches:
- Set headers inside the base query using
getState. - Use a scoped Axios request interceptor.
Base query approach:
export const axiosBaseQueryWithAuth =
(): BaseQueryFn<AxiosBaseQueryArgs, unknown, AxiosBaseQueryError> =>
async (args, { getState, signal }) => {
const token = selectAccessToken(getState() as RootState);
return axiosBaseQuery({ baseUrl: "/api" })(
{
...args,
headers: {
...args.headers,
...(token ? { authorization: `Bearer ${token}` } : {}),
},
},
{ getState, signal } as never,
{},
);
};
In real code, avoid awkward casts by composing the shared request logic cleanly. The interview point is that auth headers should be scoped to the intended API and read at request time.
Automatic Reauthorization with fetchBaseQuery
A common pattern is wrapping fetchBaseQuery to refresh tokens after a 401 Unauthorized, then retry the original request.
import type {
BaseQueryFn,
FetchArgs,
FetchBaseQueryError,
} from "@reduxjs/toolkit/query";
import { fetchBaseQuery } from "@reduxjs/toolkit/query/react";
const rawBaseQuery = fetchBaseQuery({
baseUrl: "/api",
prepareHeaders: (headers, { getState }) => {
const token = selectAccessToken(getState() as RootState);
if (token) {
headers.set("authorization", `Bearer ${token}`);
}
return headers;
},
});
export const baseQueryWithReauth: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
let result = await rawBaseQuery(args, api, extraOptions);
if (result.error?.status === 401) {
const refreshResult = await rawBaseQuery(
{ url: "/auth/refresh", method: "POST" },
api,
extraOptions,
);
if (refreshResult.data) {
api.dispatch(tokenReceived(refreshResult.data as AuthTokens));
result = await rawBaseQuery(args, api, extraOptions);
} else {
api.dispatch(loggedOut());
}
}
return result;
};
This pattern is simple, but by itself it can create multiple simultaneous refresh calls when several requests fail with 401 at the same time.
Refresh Token Queue or Mutex Pattern
When many queries fail with 401 together, only one refresh request should run. Other failed requests should wait, then retry with the refreshed token.
Example with a mutex:
import { Mutex } from "async-mutex";
const mutex = new Mutex();
export const baseQueryWithReauthQueue: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
await mutex.waitForUnlock();
let result = await rawBaseQuery(args, api, extraOptions);
if (result.error?.status === 401) {
if (!mutex.isLocked()) {
const release = await mutex.acquire();
try {
const refreshResult = await rawBaseQuery(
{ url: "/auth/refresh", method: "POST" },
api,
extraOptions,
);
if (refreshResult.data) {
api.dispatch(tokenReceived(refreshResult.data as AuthTokens));
result = await rawBaseQuery(args, api, extraOptions);
} else {
api.dispatch(loggedOut());
}
} finally {
release();
}
} else {
await mutex.waitForUnlock();
result = await rawBaseQuery(args, api, extraOptions);
}
}
return result;
};
This avoids a refresh storm. It also prevents race conditions where an older refresh response overwrites a newer token.
Axios-Based Reauthorization
You can implement reauth inside an Axios-based base query, but the same rules apply:
- Return
{ data }or{ error }. - Retry the original request only once.
- Avoid refreshing for the refresh endpoint itself.
- Coordinate concurrent refresh attempts.
- Update stored tokens before retrying.
- Preserve cancellation behavior.
Example:
let refreshPromise: Promise<AuthTokens> | null = null;
export const axiosBaseQueryWithReauth =
(): BaseQueryFn<AxiosBaseQueryArgs, unknown, AxiosBaseQueryError> =>
async (args, api) => {
const runRequest = async () => {
const token = selectAccessToken(api.getState() as RootState);
return http.request({
url: args.url,
method: args.method ?? "GET",
data: args.data,
params: args.params,
headers: {
...args.headers,
...(token ? { authorization: `Bearer ${token}` } : {}),
},
});
};
try {
const response = await runRequest();
return { data: response.data };
} catch (firstError) {
const error = firstError as AxiosError;
if (error.response?.status !== 401 || args.url === "/auth/refresh") {
return toRtkQueryError(error);
}
try {
refreshPromise ??= refreshTokens();
const tokens = await refreshPromise;
api.dispatch(tokenReceived(tokens));
const retryResponse = await runRequest();
return { data: retryResponse.data };
} catch (refreshOrRetryError) {
api.dispatch(loggedOut());
return toRtkQueryError(refreshOrRetryError as AxiosError);
} finally {
refreshPromise = null;
}
}
};
function toRtkQueryError(error: AxiosError): { error: AxiosBaseQueryError } {
return {
error: {
status: error.response?.status,
data: error.response?.data ?? error.message,
},
};
}
In many teams, this logic is cleaner in a shared Axios interceptor. In RTK Query, keeping it in baseQuery can be easier to test and easier to connect to Redux auth state.
queryFn vs Custom baseQuery
Use baseQuery when the behavior is common to most endpoints in the API slice.
Use queryFn when one endpoint has special behavior.
Example queryFn:
getCombinedProfile: build.query<CombinedProfile, string>({
async queryFn(userId, _api, _extraOptions, baseQuery) {
const userResult = await baseQuery(`/users/${userId}`);
if (userResult.error) {
return { error: userResult.error };
}
const permissionsResult = await baseQuery(`/users/${userId}/permissions`);
if (permissionsResult.error) {
return { error: permissionsResult.error };
}
return {
data: {
user: userResult.data as User,
permissions: permissionsResult.data as Permission[],
},
};
},
});
This avoids complicating the global base query for one endpoint.
transformResponse and transformErrorResponse
Use endpoint-level transforms when only one endpoint needs a different data or error shape.
getUsers: build.query<User[], void>({
query: () => "/users",
transformResponse: (response: { items: UserDto[] }) =>
response.items.map(mapUserDto),
transformErrorResponse: (response: { status: number; data: ApiErrorBody }) => ({
status: response.status,
message: response.data.message,
}),
});
This keeps base query behavior generic while allowing endpoint-specific normalization.
Automatic Retries
RTK Query provides retry utilities that can wrap a base query. Retry is useful for transient failures but dangerous when applied blindly.
Example:
import { retry } from "@reduxjs/toolkit/query/react";
const staggeredBaseQuery = retry(
fetchBaseQuery({ baseUrl: "/api" }),
{ maxRetries: 2 },
);
Good retry candidates:
- Network timeouts.
408 Request Timeout.429 Too Many Requests, ideally respecting server retry guidance.- Temporary
5xxfailures. - Idempotent reads.
Bad retry candidates:
- Validation errors.
401before refresh logic is considered.403 Forbidden.- Most non-idempotent mutations unless protected by idempotency keys.
Interviewers often look for this nuance. "Retry everything" is not a production-safe answer.
Dynamic Base URLs
Sometimes the base URL depends on tenant, region, environment, or user selection.
Example:
const dynamicBaseQuery: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> =
async (args, api, extraOptions) => {
const state = api.getState() as RootState;
const region = selectActiveRegion(state);
const baseUrl = `/api/${region}`;
const rawBaseQuery = fetchBaseQuery({ baseUrl });
return rawBaseQuery(args, api, extraOptions);
};
Be careful not to create confusing cache keys. If the same endpoint argument can return different data based on region or tenant, include that context in the query arg or endpoint design so cache entries do not collide.
Error Shape Design
A base query should return errors that are easy for UI code to interpret.
Useful fields:
status.message.code.details.correlationId.isNetworkError.isAuthError.isRetryable.
Example:
type ApiError = {
status?: number | string;
message: string;
details?: unknown;
};
Do not throw away useful server details, but do not expose secrets or raw stack traces to UI code.
Common Mistakes
Common mistakes include:
- Throwing from a custom base query instead of returning
{ error }. - Forgetting to forward
signalto Axios orfetch. - Returning inconsistent error shapes.
- Putting endpoint-specific business logic in the global base query.
- Creating multiple API slices with duplicate caches for the same resource.
- Retrying unsafe mutations automatically.
- Refreshing tokens on every
401without a mutex or queue. - Retrying the refresh endpoint and creating an infinite loop.
- Ignoring tenant or region in cache identity.
- Hiding too much behavior behind Axios interceptors and making RTK Query hard to debug.
Best Practices
Best practices include:
- Prefer
fetchBaseQueryuntil custom behavior is clearly needed. - Keep base queries small and generic.
- Always return
{ data }or{ error }. - Normalize errors once.
- Forward cancellation signals.
- Attach auth headers at request time.
- Use mutex or queue behavior for token refresh.
- Use
queryFnfor endpoint-specific async workflows. - Use
transformResponseandtransformErrorResponsefor endpoint-specific mapping. - Keep cache tags and invalidation explicit.
- Test success, HTTP error, network error, cancellation, refresh success, refresh failure, and concurrent
401cases.