The official, opinionated, batteries-included toolset for efficient Redux development
—
RTK Query React provides React-specific hooks and components for seamless integration with RTK Query data fetching. Available from @reduxjs/toolkit/query/react.
RTK Query automatically generates React hooks for each endpoint, providing type-safe data fetching and state management.
/**
* Auto-generated query hook for data fetching
* Generated for each query endpoint as `use{EndpointName}Query`
*/
type UseQueryHook<ResultType, QueryArg> = (
arg: QueryArg,
options?: UseQueryOptions<QueryArg>
) => UseQueryResult<ResultType>;
interface UseQueryOptions<QueryArg> {
/** Skip query execution */
skip?: boolean;
/** Polling interval in milliseconds */
pollingInterval?: number;
/** Skip polling when window is unfocused */
skipPollingIfUnfocused?: boolean;
/** Refetch on mount or arg change */
refetchOnMountOrArgChange?: boolean | number;
/** Refetch on window focus */
refetchOnFocus?: boolean;
/** Refetch on network reconnect */
refetchOnReconnect?: boolean;
/** Transform the hook result */
selectFromResult?: (result: UseQueryStateDefaultResult<ResultType>) => any;
}
interface UseQueryResult<ResultType> {
/** Query result data */
data: ResultType | undefined;
/** Current query error */
error: any;
/** True during initial load */
isLoading: boolean;
/** True during any fetch operation */
isFetching: boolean;
/** True when query succeeded */
isSuccess: boolean;
/** True when query failed */
isError: boolean;
/** True when query has never been run */
isUninitialized: boolean;
/** Current query status */
status: QueryStatus;
/** Current request ID */
requestId: string;
/** Request start timestamp */
startedTimeStamp?: number;
/** Request fulfillment timestamp */
fulfilledTimeStamp?: number;
/** Current query arguments */
originalArgs?: any;
/** Manual refetch function */
refetch: () => QueryActionCreatorResult<any>;
}
/**
* Auto-generated lazy query hook for manual triggering
* Generated for each query endpoint as `useLazy{EndpointName}Query`
*/
type UseLazyQueryHook<ResultType, QueryArg> = (
options?: UseLazyQueryOptions
) => [
(arg: QueryArg, preferCacheValue?: boolean) => QueryActionCreatorResult<ResultType>,
UseQueryResult<ResultType>
];
interface UseLazyQueryOptions {
/** Transform the hook result */
selectFromResult?: (result: UseQueryStateDefaultResult<any>) => any;
}
/**
* Auto-generated mutation hook for data modification
* Generated for each mutation endpoint as `use{EndpointName}Mutation`
*/
type UseMutationHook<ResultType, QueryArg> = (
options?: UseMutationOptions
) => [
(arg: QueryArg) => MutationActionCreatorResult<ResultType>,
UseMutationResult<ResultType>
];
interface UseMutationOptions {
/** Transform the hook result */
selectFromResult?: (result: UseMutationStateDefaultResult<any>) => any;
}
interface UseMutationResult<ResultType> {
/** Mutation result data */
data: ResultType | undefined;
/** Mutation error */
error: any;
/** True during mutation */
isLoading: boolean;
/** True when mutation succeeded */
isSuccess: boolean;
/** True when mutation failed */
isError: boolean;
/** True when mutation has never been called */
isUninitialized: boolean;
/** Current mutation status */
status: QueryStatus;
/** Reset mutation state */
reset: () => void;
/** Original arguments passed to mutation */
originalArgs?: any;
/** Request start timestamp */
startedTimeStamp?: number;
/** Request fulfillment timestamp */
fulfilledTimeStamp?: number;
}Usage Examples:
import { api } from './api';
// Using auto-generated query hooks
const PostsList = () => {
const {
data: posts,
error,
isLoading,
isFetching,
refetch
} = useGetPostsQuery();
const {
data: user,
isLoading: userLoading
} = useGetCurrentUserQuery(undefined, {
skip: !posts, // Skip until posts are loaded
pollingInterval: 60000 // Poll every minute
});
if (isLoading) return <div>Loading posts...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<button onClick={refetch} disabled={isFetching}>
{isFetching ? 'Refreshing...' : 'Refresh'}
</button>
{posts?.map(post => (
<PostItem key={post.id} post={post} />
))}
</div>
);
};
// Using lazy query hook
const SearchComponent = () => {
const [searchTerm, setSearchTerm] = useState('');
const [triggerSearch, { data: results, isLoading, error }] = useLazyGetSearchResultsQuery();
const handleSearch = () => {
if (searchTerm.trim()) {
triggerSearch(searchTerm);
}
};
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
/>
<button onClick={handleSearch} disabled={isLoading}>
{isLoading ? 'Searching...' : 'Search'}
</button>
{results && (
<div>
{results.map(item => <SearchResult key={item.id} item={item} />)}
</div>
)}
</div>
);
};
// Using mutation hook
const AddPostForm = () => {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [addPost, { isLoading, error, isSuccess }] = useAddPostMutation();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await addPost({ title, content }).unwrap();
setTitle('');
setContent('');
} catch (error) {
console.error('Failed to add post:', error);
}
};
useEffect(() => {
if (isSuccess) {
alert('Post added successfully!');
}
}, [isSuccess]);
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
required
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Post content"
required
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Adding...' : 'Add Post'}
</button>
{error && <div>Error: {error.message}</div>}
</form>
);
};Additional hooks for fine-grained control over query state without triggering new requests.
/**
* Hook to access query state without subscription
* Generated for each query endpoint as `use{EndpointName}QueryState`
*/
type UseQueryStateHook<ResultType, QueryArg> = (
arg: QueryArg,
options?: UseQueryStateOptions
) => UseQueryResult<ResultType>;
interface UseQueryStateOptions {
/** Skip the hook */
skip?: boolean;
/** Transform the hook result */
selectFromResult?: (result: UseQueryStateDefaultResult<any>) => any;
}
/**
* Hook for query subscription without data access
* Generated for each query endpoint as `use{EndpointName}QuerySubscription`
*/
type UseQuerySubscriptionHook<QueryArg> = (
arg: QueryArg,
options?: UseQuerySubscriptionOptions<QueryArg>
) => Pick<UseQueryResult<any>, 'refetch'>;
interface UseQuerySubscriptionOptions<QueryArg> extends UseQueryOptions<QueryArg> {
/** Skip the subscription */
skip?: boolean;
}
/**
* Lazy version of query subscription hook
* Generated for each query endpoint as `useLazy{EndpointName}QuerySubscription`
*/
type UseLazyQuerySubscriptionHook<QueryArg> = () => [
(arg: QueryArg) => QueryActionCreatorResult<any>,
Pick<UseQueryResult<any>, 'refetch'>
];Usage Examples:
// Separate data access and subscription
const PostsListOptimized = () => {
// Subscribe to updates but don't access data here
const { refetch } = useGetPostsQuerySubscription();
return (
<div>
<PostsListData />
<button onClick={refetch}>Refresh</button>
</div>
);
};
const PostsListData = () => {
// Access data without creating new subscription
const { data: posts, isLoading } = useGetPostsQueryState();
if (isLoading) return <div>Loading...</div>;
return (
<div>
{posts?.map(post => <PostItem key={post.id} post={post} />)}
</div>
);
};
// Conditional subscriptions
const ConditionalDataComponent = ({ userId }: { userId?: string }) => {
// Only subscribe when we have a user ID
useGetUserDataQuerySubscription(userId!, {
skip: !userId,
pollingInterval: 30000
});
// Access the data separately
const { data: userData } = useGetUserDataQueryState(userId!, {
skip: !userId
});
return userId ? <UserProfile user={userData} /> : <div>No user selected</div>;
};Transform and select specific parts of query results for optimized re-renders.
/**
* Transform query result before returning from hook
*/
interface SelectFromResultOptions<T> {
selectFromResult?: (result: UseQueryStateDefaultResult<T>) => any;
}
interface UseQueryStateDefaultResult<T> {
data: T | undefined;
error: any;
isLoading: boolean;
isFetching: boolean;
isSuccess: boolean;
isError: boolean;
isUninitialized: boolean;
status: QueryStatus;
requestId: string;
startedTimeStamp?: number;
fulfilledTimeStamp?: number;
originalArgs?: any;
refetch: () => QueryActionCreatorResult<any>;
}Usage Examples:
// Select only specific data to minimize re-renders
const UserName = ({ userId }: { userId: string }) => {
const userName = useGetUserQuery(userId, {
selectFromResult: ({ data, isLoading, error }) => ({
name: data?.name,
isLoading,
error
})
});
// Component only re-renders when name, loading state, or error changes
if (userName.isLoading) return <div>Loading...</div>;
if (userName.error) return <div>Error loading user</div>;
return <div>{userName.name}</div>;
};
// Select derived data
const PostsStats = () => {
const stats = useGetPostsQuery(undefined, {
selectFromResult: ({ data, isLoading }) => ({
totalPosts: data?.length ?? 0,
publishedPosts: data?.filter(p => p.published).length ?? 0,
isLoading
})
});
return (
<div>
<p>Total: {stats.totalPosts}</p>
<p>Published: {stats.publishedPosts}</p>
</div>
);
};
// Combine multiple query results
const DashboardSummary = () => {
const summary = useCombinedQueries();
return <div>{JSON.stringify(summary)}</div>;
};
const useCombinedQueries = () => {
const postsResult = useGetPostsQuery();
const usersResult = useGetUsersQuery();
const commentsResult = useGetCommentsQuery();
return useMemo(() => {
if (postsResult.isLoading || usersResult.isLoading || commentsResult.isLoading) {
return { isLoading: true };
}
if (postsResult.error || usersResult.error || commentsResult.error) {
return {
error: postsResult.error || usersResult.error || commentsResult.error
};
}
return {
isLoading: false,
data: {
totalPosts: postsResult.data?.length ?? 0,
totalUsers: usersResult.data?.length ?? 0,
totalComments: commentsResult.data?.length ?? 0,
latestPost: postsResult.data?.[0],
activeUsers: usersResult.data?.filter(u => u.isActive).length ?? 0
}
};
}, [postsResult, usersResult, commentsResult]);
};Standalone provider for using RTK Query without Redux store setup.
/**
* Provider component for standalone RTK Query usage
* @param props - Provider configuration
* @returns JSX element wrapping children with RTK Query context
*/
function ApiProvider<A extends Api<any, {}, any, any>>(props: {
/** RTK Query API instance */
api: A;
/** Enable automatic listeners setup */
setupListeners?: boolean | ((dispatch: ThunkDispatch<any, any, any>) => () => void);
/** React children */
children: React.ReactNode;
}): JSX.Element;Usage Examples:
import { ApiProvider } from '@reduxjs/toolkit/query/react';
import { api } from './api';
// Standalone RTK Query usage without Redux store
const App = () => {
return (
<ApiProvider
api={api}
setupListeners={true} // Enable automatic refetch on focus/reconnect
>
<PostsList />
<AddPostForm />
</ApiProvider>
);
};
// Custom listener setup
const AppWithCustomListeners = () => {
const setupCustomListeners = useCallback((dispatch) => {
// Custom listener setup
const unsubscribe = setupListeners(dispatch, {
onFocus: () => console.log('App focused'),
onOnline: () => console.log('App online')
});
return unsubscribe;
}, []);
return (
<ApiProvider
api={api}
setupListeners={setupCustomListeners}
>
<AppContent />
</ApiProvider>
);
};
// Multiple API providers
const MultiApiApp = () => {
return (
<ApiProvider api={postsApi}>
<ApiProvider api={usersApi}>
<AppContent />
</ApiProvider>
</ApiProvider>
);
};Special hooks for handling paginated/infinite data patterns.
/**
* Auto-generated infinite query hook
* Generated for each infinite query endpoint as `use{EndpointName}InfiniteQuery`
*/
type UseInfiniteQueryHook<ResultType, QueryArg, PageParam> = (
arg: QueryArg,
options?: UseInfiniteQueryOptions<QueryArg>
) => UseInfiniteQueryResult<ResultType, PageParam>;
interface UseInfiniteQueryOptions<QueryArg> extends UseQueryOptions<QueryArg> {
/** Maximum number of pages to keep in cache */
maxPages?: number;
}
interface UseInfiniteQueryResult<ResultType, PageParam> {
/** All pages of data */
data: ResultType | undefined;
/** Current query error */
error: any;
/** Loading states */
isLoading: boolean;
isFetching: boolean;
isFetchingNextPage: boolean;
isFetchingPreviousPage: boolean;
/** Success/error states */
isSuccess: boolean;
isError: boolean;
/** Pagination info */
hasNextPage: boolean;
hasPreviousPage: boolean;
/** Pagination actions */
fetchNextPage: () => void;
fetchPreviousPage: () => void;
/** Refetch all pages */
refetch: () => void;
}Usage Examples:
// Define infinite query endpoint
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
getPostsInfinite: builder.infiniteQuery<
{ posts: Post[]; total: number; page: number },
{ limit: number },
number
>({
query: ({ limit = 10 }) => ({
url: 'posts',
params: { limit, offset: 0 }
}),
getNextPageParam: (lastPage, allPages) => {
const totalLoaded = allPages.length * 10;
return totalLoaded < lastPage.total ? allPages.length + 1 : undefined;
},
getCombinedResult: (pages) => ({
posts: pages.flatMap(page => page.posts),
total: pages[0]?.total ?? 0,
currentPage: pages.length
})
})
})
});
// Using infinite query hook
const InfinitePostsList = () => {
const {
data,
error,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage
} = useGetPostsInfiniteQuery({ limit: 10 });
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
{data?.posts.map(post => (
<PostItem key={post.id} post={post} />
))}
{hasNextPage && (
<button
onClick={fetchNextPage}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading more...' : 'Load More'}
</button>
)}
</div>
);
};
// Infinite scroll implementation
const InfiniteScrollPosts = () => {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useGetPostsInfiniteQuery({ limit: 20 });
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 1.0 }
);
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current);
}
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
return (
<div>
{data?.posts.map(post => (
<PostItem key={post.id} post={post} />
))}
<div ref={loadMoreRef}>
{isFetchingNextPage && <div>Loading more posts...</div>}
</div>
</div>
);
};const OptimisticPostEditor = ({ postId }: { postId: string }) => {
const [updatePost] = useUpdatePostMutation();
const queryClient = useQueryClient();
const handleOptimisticUpdate = async (changes: Partial<Post>) => {
// Store original data for potential rollback
const previousPost = queryClient.getQueryData(['post', postId]);
// Optimistically update UI
queryClient.setQueryData(['post', postId], (old: Post | undefined) =>
old ? { ...old, ...changes } : undefined
);
try {
await updatePost({ id: postId, patch: changes }).unwrap();
} catch (error) {
// Rollback on error
queryClient.setQueryData(['post', postId], previousPost);
throw error;
}
};
return <PostEditor onSave={handleOptimisticUpdate} />;
};// Keep multiple related queries in sync
const SynchronizedDataComponent = ({ userId }: { userId: string }) => {
const { data: user } = useGetUserQuery(userId);
const { data: posts } = useGetUserPostsQuery(userId, {
skip: !user // Wait for user data
});
const { data: profile } = useGetUserProfileQuery(userId, {
skip: !user
});
// All queries are synchronized - profile and posts only load after user
return (
<div>
{user && <UserInfo user={user} />}
{posts && <PostsList posts={posts} />}
{profile && <UserProfile profile={profile} />}
</div>
);
};// Compose multiple RTK Query hooks into custom hooks
const usePostWithAuthor = (postId: string) => {
const { data: post, ...postQuery } = useGetPostQuery(postId);
const { data: author, ...authorQuery } = useGetUserQuery(post?.authorId!, {
skip: !post?.authorId
});
return {
post,
author,
isLoading: postQuery.isLoading || authorQuery.isLoading,
error: postQuery.error || authorQuery.error,
refetch: () => {
postQuery.refetch();
if (post?.authorId) {
authorQuery.refetch();
}
}
};
};
// Usage
const PostWithAuthorComponent = ({ postId }: { postId: string }) => {
const { post, author, isLoading, error } = usePostWithAuthor(postId);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading post</div>;
return (
<article>
<h1>{post?.title}</h1>
<p>By {author?.name}</p>
<div>{post?.content}</div>
</article>
);
};Constants and utilities specific to RTK Query React integration.
/**
* Special value representing uninitialized query state
* Used internally by RTK Query React hooks
*/
const UNINITIALIZED_VALUE: unique symbol;Usage Examples:
import { UNINITIALIZED_VALUE } from '@reduxjs/toolkit/query/react';
// Check if query result is truly uninitialized (vs undefined data)
const MyComponent = () => {
const { data, isUninitialized } = useGetDataQuery();
// Direct comparison (rarely needed in application code)
if (data === UNINITIALIZED_VALUE) {
console.log('Query has not been started');
}
// Prefer using the isUninitialized flag
if (isUninitialized) {
return <div>Query not started</div>;
}
return <div>{data ? 'Has data' : 'No data'}</div>;
};Install with Tessl CLI
npx tessl i tessl/npm-reduxjs--toolkit