The official, opinionated, batteries-included toolset for efficient Redux development
—
RTK Query provides a powerful data fetching solution with automatic caching, background updates, and comprehensive state management. Available from @reduxjs/toolkit/query.
Creates an RTK Query API slice with endpoints for data fetching and caching.
/**
* Creates RTK Query API slice with endpoints for data fetching
* @param options - API configuration options
* @returns API object with endpoints, reducer, and middleware
*/
function createApi<
BaseQuery extends BaseQueryFn,
Definitions extends EndpointDefinitions,
ReducerPath extends string = 'api',
TagTypes extends string = never
>(options: CreateApiOptions<BaseQuery, Definitions, ReducerPath, TagTypes>): Api<BaseQuery, Definitions, ReducerPath, TagTypes>;
interface CreateApiOptions<BaseQuery, Definitions, ReducerPath, TagTypes> {
/** Unique key for the API slice in Redux state */
reducerPath?: ReducerPath;
/** Base query function for making requests */
baseQuery: BaseQuery;
/** Cache tag type names for invalidation */
tagTypes?: readonly TagTypes[];
/** Endpoint definitions */
endpoints: (builder: EndpointBuilder<BaseQuery, TagTypes, ReducerPath>) => Definitions;
/** Custom query argument serialization */
serializeQueryArgs?: SerializeQueryArgs<any>;
/** Global cache retention time in seconds */
keepUnusedDataFor?: number;
/** Global refetch behavior on mount or arg change */
refetchOnMountOrArgChange?: boolean | number;
/** Global refetch on window focus */
refetchOnFocus?: boolean;
/** Global refetch on network reconnect */
refetchOnReconnect?: boolean;
}
interface Api<BaseQuery, Definitions, ReducerPath, TagTypes> {
/** Generated endpoint objects with query/mutation methods */
endpoints: ApiEndpoints<Definitions, BaseQuery, TagTypes>;
/** Reducer function for the API slice */
reducer: Reducer<CombinedState<Definitions, TagTypes, ReducerPath>>;
/** Middleware for handling async operations */
middleware: Middleware<{}, any, Dispatch<AnyAction>>;
/** Utility functions */
util: {
/** Get running query promises */
getRunningOperationPromises(): Record<string, QueryActionCreatorResult<any> | MutationActionCreatorResult<any>>;
/** Reset API state */
resetApiState(): PayloadAction<void>;
/** Invalidate cache tags */
invalidateTags(tags: readonly TagDescription<TagTypes>[]): PayloadAction<TagDescription<TagTypes>[]>;
/** Select cache entries by tags */
selectCachedArgsForQuery<T>(state: any, endpointName: T): readonly unknown[];
/** Select invalidated cache entries */
selectInvalidatedBy(state: any, tags: readonly TagDescription<TagTypes>[]): readonly unknown[];
};
/** Internal cache key functions */
internalActions: {
onOnline(): PayloadAction<void>;
onOffline(): PayloadAction<void>;
onFocus(): PayloadAction<void>;
onFocusLost(): PayloadAction<void>;
};
}Usage Examples:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query';
// Basic API definition
const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: '/api/',
}),
tagTypes: ['Post', 'User'],
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => 'posts',
providesTags: ['Post']
}),
getPost: builder.query<Post, number>({
query: (id) => `posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }]
}),
addPost: builder.mutation<Post, Partial<Post>>({
query: (newPost) => ({
url: 'posts',
method: 'POST',
body: newPost,
}),
invalidatesTags: ['Post']
}),
updatePost: builder.mutation<Post, { id: number; patch: Partial<Post> }>({
query: ({ id, patch }) => ({
url: `posts/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }]
}),
deletePost: builder.mutation<{ success: boolean; id: number }, number>({
query: (id) => ({
url: `posts/${id}`,
method: 'DELETE',
}),
invalidatesTags: (result, error, id) => [{ type: 'Post', id }]
})
})
});
// Export hooks and actions
export const {
useGetPostsQuery,
useGetPostQuery,
useAddPostMutation,
useUpdatePostMutation,
useDeletePostMutation
} = postsApi;
// Configure store
const store = configureStore({
reducer: {
postsApi: postsApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(postsApi.middleware)
});RTK Query provides base query implementations for common scenarios.
/**
* Built-in base query using fetch API
* @param options - Fetch configuration options
* @returns Base query function for HTTP requests
*/
function fetchBaseQuery(options?: FetchBaseQueryArgs): BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError>;
interface FetchBaseQueryArgs {
/** Base URL for all requests */
baseUrl?: string;
/** Function to prepare request headers */
prepareHeaders?: (headers: Headers, api: { getState: () => unknown; extra: any; endpoint: string; type: 'query' | 'mutation'; forced?: boolean }) => Headers | void;
/** Custom fetch implementation */
fetchFn?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
/** URL parameter serialization function */
paramsSerializer?: (params: Record<string, any>) => string;
/** Request timeout in milliseconds */
timeout?: number;
/** Response validation function */
validateStatus?: (response: Response, body: any) => boolean;
}
interface FetchArgs {
/** Request URL */
url: string;
/** HTTP method */
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
/** Request headers */
headers?: HeadersInit;
/** Request body */
body?: any;
/** URL parameters */
params?: Record<string, any>;
/** Response parsing method */
responseHandler?: 'content-type' | 'json' | 'text' | ((response: Response) => Promise<any>);
/** Response validation */
validateStatus?: (response: Response, body: any) => boolean;
/** Request timeout */
timeout?: number;
}
type FetchBaseQueryError =
| { status: number; data: any }
| { status: 'FETCH_ERROR'; error: string; data?: undefined }
| { status: 'PARSING_ERROR'; originalStatus: number; data: string; error: string }
| { status: 'TIMEOUT_ERROR'; error: string; data?: undefined }
| { status: 'CUSTOM_ERROR'; error: string; data?: any };
/**
* Placeholder base query for APIs that only use queryFn
* @returns Fake base query that throws error if used
*/
function fakeBaseQuery<ErrorType = string>(): BaseQueryFn<any, any, ErrorType>;Usage Examples:
// Basic fetch base query
const api = createApi({
baseQuery: fetchBaseQuery({
baseUrl: 'https://api.example.com/',
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.token;
if (token) {
headers.set('authorization', `Bearer ${token}`);
}
headers.set('content-type', 'application/json');
return headers;
},
timeout: 10000
}),
endpoints: (builder) => ({
getData: builder.query<Data, string>({
query: (id) => ({
url: `data/${id}`,
params: { include: 'details' }
})
})
})
});
// Custom response handling
const apiWithCustomResponse = createApi({
baseQuery: fetchBaseQuery({
baseUrl: '/api/',
validateStatus: (response, result) => {
return response.ok && result?.success === true;
}
}),
endpoints: (builder) => ({
getProtectedData: builder.query<ProtectedData, void>({
query: () => ({
url: 'protected',
responseHandler: async (response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
return data.payload; // Extract nested payload
}
})
})
})
});
// Using fakeBaseQuery with queryFn
const apiWithCustomLogic = createApi({
baseQuery: fakeBaseQuery<CustomError>(),
endpoints: (builder) => ({
getComplexData: builder.query<ComplexData, string>({
queryFn: async (arg, queryApi, extraOptions, baseQuery) => {
try {
// Custom logic for data fetching
const step1 = await customApiClient.fetchUserData(arg);
const step2 = await customApiClient.fetchUserPreferences(step1.id);
return { data: { ...step1, preferences: step2 } };
} catch (error) {
return { error: { status: 'CUSTOM_ERROR', error: error.message } };
}
}
})
})
});Define query and mutation endpoints with comprehensive configuration options.
/**
* Builder object for defining endpoints
*/
interface EndpointBuilder<BaseQuery, TagTypes, ReducerPath> {
/** Define a query endpoint for data fetching */
query<ResultType, QueryArg = void>(
definition: QueryDefinition<QueryArg, BaseQuery, TagTypes, ResultType, ReducerPath>
): QueryDefinition<QueryArg, BaseQuery, TagTypes, ResultType, ReducerPath>;
/** Define a mutation endpoint for data modification */
mutation<ResultType, QueryArg = void>(
definition: MutationDefinition<QueryArg, BaseQuery, TagTypes, ResultType, ReducerPath>
): MutationDefinition<QueryArg, BaseQuery, TagTypes, ResultType, ReducerPath>;
/** Define an infinite query endpoint for paginated data */
infiniteQuery<ResultType, QueryArg = void, PageParam = unknown>(
definition: InfiniteQueryDefinition<QueryArg, BaseQuery, TagTypes, ResultType, PageParam, ReducerPath>
): InfiniteQueryDefinition<QueryArg, BaseQuery, TagTypes, ResultType, PageParam, ReducerPath>;
}
interface QueryDefinition<QueryArg, BaseQuery, TagTypes, ResultType, ReducerPath> {
/** Query function or args */
query: (arg: QueryArg) => any;
/** Custom query implementation */
queryFn?: (arg: QueryArg, api: QueryApi<ReducerPath, any, any, any>, extraOptions: any, baseQuery: BaseQuery) => Promise<QueryFnResult<ResultType>> | QueryFnResult<ResultType>;
/** Transform the response data */
transformResponse?: (baseQueryReturnValue: any, meta: any, arg: QueryArg) => ResultType | Promise<ResultType>;
/** Transform error responses */
transformErrorResponse?: (baseQueryReturnValue: any, meta: any, arg: QueryArg) => any;
/** Cache tags this query provides */
providesTags?: ResultDescription<TagTypes, ResultType, QueryArg>;
/** Cache retention time override */
keepUnusedDataFor?: number;
/** Lifecycle callback when query starts */
onQueryStarted?: (arg: QueryArg, api: QueryLifecycleApi<QueryArg, BaseQuery, ResultType, ReducerPath>) => Promise<void> | void;
/** Lifecycle callback when cache entry is added */
onCacheEntryAdded?: (arg: QueryArg, api: CacheLifecycleApi<QueryArg, BaseQuery, ResultType, ReducerPath>) => Promise<void> | void;
}
interface MutationDefinition<QueryArg, BaseQuery, TagTypes, ResultType, ReducerPath> {
/** Mutation function or args */
query: (arg: QueryArg) => any;
/** Custom mutation implementation */
queryFn?: (arg: QueryArg, api: QueryApi<ReducerPath, any, any, any>, extraOptions: any, baseQuery: BaseQuery) => Promise<QueryFnResult<ResultType>> | QueryFnResult<ResultType>;
/** Transform the response data */
transformResponse?: (baseQueryReturnValue: any, meta: any, arg: QueryArg) => ResultType | Promise<ResultType>;
/** Transform error responses */
transformErrorResponse?: (baseQueryReturnValue: any, meta: any, arg: QueryArg) => any;
/** Cache tags to invalidate */
invalidatesTags?: ResultDescription<TagTypes, ResultType, QueryArg>;
/** Lifecycle callback when mutation starts */
onQueryStarted?: (arg: QueryArg, api: MutationLifecycleApi<QueryArg, BaseQuery, ResultType, ReducerPath>) => Promise<void> | void;
/** Lifecycle callback when cache entry is added */
onCacheEntryAdded?: (arg: QueryArg, api: CacheLifecycleApi<QueryArg, BaseQuery, ResultType, ReducerPath>) => Promise<void> | void;
}
type ResultDescription<TagTypes, ResultType, QueryArg> =
| readonly TagTypes[]
| readonly TagDescription<TagTypes>[]
| ((result: ResultType | undefined, error: any, arg: QueryArg) => readonly TagDescription<TagTypes>[]);
interface TagDescription<TagTypes> {
type: TagTypes;
id?: string | number;
}
interface QueryFnResult<T> {
data?: T;
error?: any;
meta?: any;
}Usage Examples:
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post', 'User', 'Comment'],
endpoints: (builder) => ({
// Simple query
getPosts: builder.query<Post[], void>({
query: () => 'posts',
providesTags: ['Post']
}),
// Query with parameters
getPostsByUser: builder.query<Post[], { userId: string; limit?: number }>({
query: ({ userId, limit = 10 }) => ({
url: 'posts',
params: { userId, limit }
}),
providesTags: (result, error, { userId }) => [
'Post',
{ type: 'User', id: userId }
]
}),
// Query with response transformation
getPostWithComments: builder.query<PostWithComments, string>({
query: (id) => `posts/${id}`,
transformResponse: (response: { post: Post; comments: Comment[] }) => ({
...response.post,
comments: response.comments.sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
}),
providesTags: (result, error, id) => [
{ type: 'Post', id },
{ type: 'Comment', id: 'LIST' }
]
}),
// Custom query function
getAnalytics: builder.query<Analytics, { startDate: string; endDate: string }>({
queryFn: async (arg, queryApi, extraOptions, baseQuery) => {
try {
// Multiple API calls
const [posts, users, comments] = await Promise.all([
baseQuery(`analytics/posts?start=${arg.startDate}&end=${arg.endDate}`),
baseQuery(`analytics/users?start=${arg.startDate}&end=${arg.endDate}`),
baseQuery(`analytics/comments?start=${arg.startDate}&end=${arg.endDate}`)
]);
if (posts.error || users.error || comments.error) {
return { error: 'Failed to fetch analytics data' };
}
return {
data: {
posts: posts.data,
users: users.data,
comments: comments.data,
summary: calculateSummary(posts.data, users.data, comments.data)
}
};
} catch (error) {
return { error: error.message };
}
}
}),
// Mutation with optimistic updates
updatePost: builder.mutation<Post, { id: string; patch: Partial<Post> }>({
query: ({ id, patch }) => ({
url: `posts/${id}`,
method: 'PATCH',
body: patch
}),
onQueryStarted: async ({ id, patch }, { dispatch, queryFulfilled }) => {
// Optimistic update
const patchResult = dispatch(
api.util.updateQueryData('getPost', id, (draft) => {
Object.assign(draft, patch);
})
);
try {
await queryFulfilled;
} catch {
// Revert on failure
patchResult.undo();
}
},
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }]
}),
// Mutation with side effects
deletePost: builder.mutation<void, string>({
query: (id) => ({
url: `posts/${id}`,
method: 'DELETE'
}),
onQueryStarted: async (id, { dispatch, queryFulfilled }) => {
try {
await queryFulfilled;
// Additional side effects
dispatch(showNotification('Post deleted successfully'));
dispatch(api.util.invalidateTags([{ type: 'Post', id }]));
} catch (error) {
dispatch(showNotification('Failed to delete post'));
}
}
})
})
});RTK Query provides sophisticated caching with tag-based invalidation and cache lifecycle management.
/**
* Skip query execution when passed as argument
*/
const skipToken: unique symbol;
/**
* Query status enumeration
*/
enum QueryStatus {
uninitialized = 'uninitialized',
pending = 'pending',
fulfilled = 'fulfilled',
rejected = 'rejected'
}
/**
* Cache state structure for queries and mutations
*/
interface QueryState<T> {
/** Query status */
status: QueryStatus;
/** Query result data */
data?: T;
/** Current request ID */
requestId?: string;
/** Error information */
error?: any;
/** Fulfillment timestamp */
fulfilledTimeStamp?: number;
/** Start timestamp */
startedTimeStamp?: number;
/** End timestamp */
endedTimeStamp?: number;
}
interface MutationState<T> {
/** Mutation status */
status: QueryStatus;
/** Mutation result data */
data?: T;
/** Current request ID */
requestId?: string;
/** Error information */
error?: any;
}Usage Examples:
// Using skipToken to conditionally skip queries
const UserProfile = ({ userId }: { userId?: string }) => {
const { data: user, error, isLoading } = useGetUserQuery(
userId ?? skipToken // Skip query if no userId
);
if (!userId) {
return <div>Please select a user</div>;
}
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading user</div>;
return <div>{user?.name}</div>;
};
// Manual cache updates
const PostsList = () => {
const { data: posts } = useGetPostsQuery();
const [updatePost] = useUpdatePostMutation();
const handleOptimisticUpdate = (id: string, changes: Partial<Post>) => {
// Manually update cache
store.dispatch(
api.util.updateQueryData('getPosts', undefined, (draft) => {
const post = draft.find(p => p.id === id);
if (post) {
Object.assign(post, changes);
}
})
);
// Then perform actual update
updatePost({ id, patch: changes });
};
return (
<div>
{posts?.map(post => (
<PostItem
key={post.id}
post={post}
onUpdate={handleOptimisticUpdate}
/>
))}
</div>
);
};
// Prefetching data
const PostsListWithPrefetch = () => {
const dispatch = useAppDispatch();
useEffect(() => {
// Prefetch data
dispatch(api.util.prefetch('getPosts', undefined, { force: false }));
// Prefetch related data
dispatch(api.util.prefetch('getUsers', undefined));
}, [dispatch]);
const { data: posts } = useGetPostsQuery();
return <div>...</div>;
};// Real-time updates with cache entry lifecycle
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => 'posts',
onCacheEntryAdded: async (
arg,
{ updateCachedData, cacheDataLoaded, cacheEntryRemoved }
) => {
// Wait for initial data load
await cacheDataLoaded;
// Setup WebSocket connection
const ws = new WebSocket('ws://localhost:8080/posts');
ws.addEventListener('message', (event) => {
const update = JSON.parse(event.data);
updateCachedData((draft) => {
switch (update.type) {
case 'POST_ADDED':
draft.push(update.post);
break;
case 'POST_UPDATED':
const index = draft.findIndex(p => p.id === update.post.id);
if (index !== -1) {
draft[index] = update.post;
}
break;
case 'POST_DELETED':
return draft.filter(p => p.id !== update.postId);
}
});
});
// Cleanup on cache entry removal
await cacheEntryRemoved;
ws.close();
}
})
})
});const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
// Polling query
getLiveData: builder.query<LiveData, void>({
query: () => 'live-data',
// Automatic polling every 5 seconds
pollingInterval: 5000
}),
// Custom request deduplication
getExpensiveData: builder.query<ExpensiveData, string>({
query: (id) => `expensive-data/${id}`,
serializeQueryArgs: ({ queryArgs, endpointDefinition, endpointName }) => {
// Custom serialization for deduplication
return `${endpointName}(${queryArgs})`;
},
// Cache for 10 minutes
keepUnusedDataFor: 600
})
})
});
// Component with polling control
const LiveDataComponent = () => {
const [pollingEnabled, setPollingEnabled] = useState(true);
const { data, error, isLoading } = useGetLiveDataQuery(undefined, {
pollingInterval: pollingEnabled ? 5000 : 0,
skipPollingIfUnfocused: true
});
return (
<div>
<button onClick={() => setPollingEnabled(!pollingEnabled)}>
{pollingEnabled ? 'Stop' : 'Start'} Polling
</button>
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
</div>
);
};// Setup automatic refetching listeners
import { setupListeners } from '@reduxjs/toolkit/query';
// Enable refetching on focus/reconnect
setupListeners(store.dispatch, {
onFocus: () => console.log('Window focused'),
onFocusLost: () => console.log('Window focus lost'),
onOnline: () => console.log('Network online'),
onOffline: () => console.log('Network offline')
});
// API with custom refetch behavior
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
refetchOnFocus: true,
refetchOnReconnect: true,
endpoints: (builder) => ({
getCriticalData: builder.query<CriticalData, void>({
query: () => 'critical-data',
// Always refetch on mount
refetchOnMountOrArgChange: true
}),
getCachedData: builder.query<CachedData, void>({
query: () => 'cached-data',
// Only refetch if older than 5 minutes
refetchOnMountOrArgChange: 300
})
})
});Additional utility functions for advanced RTK Query usage.
/**
* Default function for serializing query arguments into cache keys
* @param args - Query arguments to serialize
* @param endpointDefinition - Endpoint definition for context
* @param endpointName - Name of the endpoint
* @returns Serialized string representation of arguments
*/
function defaultSerializeQueryArgs(
args: any,
endpointDefinition: EndpointDefinition<any, any, any, any>,
endpointName: string
): string;
/**
* Copy value while preserving structural sharing for performance
* Used internally for immutable updates with minimal re-renders
* @param oldObj - Previous value
* @param newObj - New value to merge
* @returns Value with structural sharing where possible
*/
function copyWithStructuralSharing<T>(oldObj: T, newObj: T): T;
/**
* Retry utility for enhanced error handling in base queries
* @param baseQuery - Base query function to retry
* @param options - Retry configuration options
* @returns Enhanced base query with retry logic
*/
function retry<Args extends any, Return extends any, Error extends any>(
baseQuery: BaseQueryFn<Args, Return, Error>,
options: RetryOptions
): BaseQueryFn<Args, Return, Error>;
interface RetryOptions {
/** Maximum number of retry attempts */
maxRetries: number;
/** Function to determine if error should trigger retry */
retryCondition?: (error: any, args: any, extraOptions: any) => boolean;
/** Delay between retries in milliseconds */
backoff?: (attempt: number, maxRetries: number) => number;
}Usage Examples:
import {
defaultSerializeQueryArgs,
copyWithStructuralSharing,
retry
} from '@reduxjs/toolkit/query';
// Custom query argument serialization
const customApi = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
serializeQueryArgs: ({ queryArgs, endpointDefinition, endpointName }) => {
// Use default serialization with custom prefix
const defaultKey = defaultSerializeQueryArgs(queryArgs, endpointDefinition, endpointName);
return `custom:${defaultKey}`;
},
endpoints: (builder) => ({
getUser: builder.query<User, { userId: string; include?: string[] }>({
query: ({ userId, include = [] }) => ({
url: `users/${userId}`,
params: { include: include.join(',') }
})
})
})
});
// Enhanced base query with retry logic
const resilientBaseQuery = retry(
fetchBaseQuery({ baseUrl: '/api' }),
{
maxRetries: 3,
retryCondition: (error, args, extraOptions) => {
// Retry on network errors and 5xx server errors
return error.status >= 500 || error.name === 'NetworkError';
},
backoff: (attempt, maxRetries) => {
// Exponential backoff: 1s, 2s, 4s
return Math.min(1000 * (2 ** attempt), 10000);
}
}
);
const apiWithRetry = createApi({
baseQuery: resilientBaseQuery,
endpoints: (builder) => ({
getCriticalData: builder.query<Data, void>({
query: () => 'critical-data'
})
})
});
// Structural sharing in transformResponse
const optimizedApi = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
getOptimizedData: builder.query<ProcessedData, void>({
query: () => 'data',
transformResponse: (response: RawData, meta, arg) => {
const processed = processData(response);
// copyWithStructuralSharing is used internally by RTK Query
// but can be useful for custom transform logic
return {
...processed,
metadata: {
fetchedAt: Date.now(),
version: response.version
}
};
}
})
})
});Install with Tessl CLI
npx tessl i tessl/npm-reduxjs--toolkit