| name | tanstack-query |
| description | TanStack Query v5 patterns for React. Reference for queries, mutations, infinite scroll, and cache management. |
| context | fork |
| user_invocable | true |
TanStack Query v5 Patterns
Quick reference for @tanstack/react-query v5 patterns used in OpenNews.
Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
Query Keys Convention
export const queryKeys = {
feed: {
all: ['feed'] as const,
list: (filters: { tag?: string }) => ['feed', 'list', filters] as const,
},
article: {
all: ['article'] as const,
detail: (topicId: string) => ['article', topicId] as const,
},
settings: ['settings'] as const,
sources: ['sources'] as const,
tags: ['tags'] as const,
} as const;
Standard Query
import { useQuery } from '@tanstack/react-query';
function useSettings() {
return useQuery({
queryKey: queryKeys.settings,
queryFn: () => api.get<Settings>('/api/v1/settings'),
});
}
Mutation with Cache Invalidation
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useUpdateSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Settings>) => api.put('/api/v1/settings', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settings });
},
});
}
Infinite Query (Feed Pagination)
This is the primary pattern for the daily feed with cursor-based pagination.
import { useInfiniteQuery } from '@tanstack/react-query';
interface FeedResponse {
days: DayWithTopics[];
nextCursor?: string;
}
function useFeed(tag?: string) {
return useInfiniteQuery({
queryKey: queryKeys.feed.list({ tag }),
queryFn: async ({ pageParam }) => {
const params = new URLSearchParams();
if (pageParam) params.set('cursor', pageParam);
if (tag) params.set('tag', tag);
params.set('limit', '3');
return api.get<FeedResponse>(`/api/v1/feed?${params}`);
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
}
function Feed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useFeed(selectedTag);
const allDays = data?.pages.flatMap((page) => page.days) ?? [];
const observerRef = useRef<IntersectionObserver>();
const loadMoreRef = useCallback((node: HTMLDivElement | null) => {
if (isFetchingNextPage) return;
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
});
if (node) observerRef.current.observe(node);
}, [isFetchingNextPage, hasNextPage, fetchNextPage]);
return (
<>
{allDays.map((day) => <DaySection key={day.date} day={day} />)}
<div ref={loadMoreRef} />
</>
);
}
Optimistic Update
function useDeleteSource() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/sources/${id}`),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: queryKeys.sources });
const previous = queryClient.getQueryData<Source[]>(queryKeys.sources);
queryClient.setQueryData<Source[]>(queryKeys.sources, (old) =>
old?.filter((s) => s.id !== id),
);
return { previous };
},
onError: (_err, _id, context) => {
queryClient.setQueryData(queryKeys.sources, context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.sources });
},
});
}
Conditional Fetching
function useArticle(topicId: string) {
return useQuery({
queryKey: queryKeys.article.detail(topicId),
queryFn: () => api.get<{ cached: boolean; content?: string }>(`/api/v1/article/${topicId}`),
enabled: !!topicId,
});
}
Prefetching
function TopicCard({ topic }: { topic: Topic }) {
const queryClient = useQueryClient();
const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: queryKeys.article.detail(String(topic.id)),
queryFn: () => api.get(`/api/v1/article/${topic.id}`),
staleTime: 60 * 1000,
});
};
return <Link to={`/article/${topic.id}`} onMouseEnter={handleMouseEnter}>...</Link>;
}