Chapter 20: Data Fetching with TanStack Query
TanStack Query is the server state management layer. It handles fetching, caching, synchronizing, and updating data from your API — automatically. This chapter covers query key design, caching strategies, mutations, and integration with our Effect architecture.
Core Concepts
TanStack Query manages server state — data that lives on the server and is cached locally. This is fundamentally different from client state (UI state, form state, user preferences) covered in Chapter 21.
The Query Client
// shared/lib/query-client.ts
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // Data is fresh for 1 minute
gcTime: 5 * 60_000, // Garbage collect after 5 minutes
retry: 1, // Retry failed requests once
refetchOnWindowFocus: false, // Don't refetch on tab focus
},
mutations: {
retry: 0, // Don't retry mutations
},
},
});
Query Keys: The Foundation
Query keys are the primary mechanism for caching and invalidation. Design them carefully.
Key Convention
// Hierarchical keys: [entity, operation, ...params]
["projects", "list", { orgId, status, page }] // Project list with filters
["projects", "detail", projectId] // Single project
["projects", "detail", projectId, "tasks"] // Tasks for a project
["users", "me"] // Current user
["users", "detail", userId] // Specific user
Query Key Factory
Centralize key creation to prevent typos and ensure consistency:
// features/projects/hooks/query-keys.ts
export const projectKeys = {
all: ["projects"] as const,
lists: () => [...projectKeys.all, "list"] as const,
list: (filters: ProjectFilters) => [...projectKeys.lists(), filters] as const,
details: () => [...projectKeys.all, "detail"] as const,
detail: (id: string) => [...projectKeys.details(), id] as const,
detailTasks: (id: string) => [...projectKeys.detail(id), "tasks"] as const,
};
// Usage
queryClient.invalidateQueries({ queryKey: projectKeys.all }); // Invalidate everything
queryClient.invalidateQueries({ queryKey: projectKeys.lists() }); // Invalidate all lists
queryClient.invalidateQueries({ queryKey: projectKeys.detail("1") }); // Invalidate one project
Query Options Factory Pattern
Create reusable query options objects that work in both hooks and route loaders:
// features/projects/hooks/use-projects.ts
import { queryOptions } from "@tanstack/react-query";
import { AppRuntime } from "@/shared/lib/effect-runtime";
import { ProjectRepository } from "../domain/ports/project-repository";
export const projectListOptions = (filters: ProjectFilters) =>
queryOptions({
queryKey: projectKeys.list(filters),
queryFn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const repo = yield* ProjectRepository;
return yield* repo.findByOrganization(filters);
})
),
staleTime: 30_000,
});
export const projectDetailOptions = (projectId: string) =>
queryOptions({
queryKey: projectKeys.detail(projectId),
queryFn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const repo = yield* ProjectRepository;
return yield* repo.findById(projectId);
})
),
staleTime: 60_000,
});
// In a route loader:
loader: ({ params, context }) =>
context.queryClient.ensureQueryData(projectDetailOptions(params.projectId)),
// In a component:
function ProjectList({ filters }: { filters: ProjectFilters }) {
const { data, isLoading, error } = useQuery(projectListOptions(filters));
// ...
}
Mutations
Basic Mutation
export function useCreateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateProjectInput) =>
AppRuntime.runPromise(createProject(input, getCurrentUserId())),
onSuccess: (newProject) => {
// Invalidate project lists so they refetch
queryClient.invalidateQueries({ queryKey: projectKeys.lists() });
// Optionally set the new project in cache immediately
queryClient.setQueryData(
projectKeys.detail(newProject.id),
newProject
);
},
});
}
Optimistic Updates
Update the UI immediately while the server processes the request:
export function useUpdateProjectStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ projectId, status }: { projectId: string; status: ProjectStatus }) =>
AppRuntime.runPromise(
Effect.gen(function* () {
const repo = yield* ProjectRepository;
return yield* repo.update(projectId, { status });
})
),
// Optimistic update
onMutate: async ({ projectId, status }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: projectKeys.detail(projectId) });
// Snapshot previous value
const previous = queryClient.getQueryData(projectKeys.detail(projectId));
// Optimistically update the cache
queryClient.setQueryData(
projectKeys.detail(projectId),
(old: Project | undefined) =>
old ? { ...old, status, updatedAt: new Date() } : old
);
return { previous };
},
// Rollback on error
onError: (_error, { projectId }, context) => {
if (context?.previous) {
queryClient.setQueryData(projectKeys.detail(projectId), context.previous);
}
},
// Refetch after mutation settles (success or error)
onSettled: (_data, _error, { projectId }) => {
queryClient.invalidateQueries({ queryKey: projectKeys.detail(projectId) });
},
});
}
Pagination
function useProjectList(filters: ProjectFilters) {
return useQuery({
queryKey: projectKeys.list(filters),
queryFn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const repo = yield* ProjectRepository;
return yield* repo.list(filters);
})
),
placeholderData: keepPreviousData, // Keep previous page data while loading next
});
}
function ProjectListPage() {
const { page, pageSize, ...filters } = Route.useSearch();
const { data, isLoading, isPlaceholderData } = useProjectList({
...filters,
page,
pageSize,
});
return (
<div className={isPlaceholderData ? "opacity-60" : ""}>
<ProjectGrid projects={data?.items ?? []} />
<Pagination
page={page}
totalPages={data?.totalPages ?? 0}
isLoading={isPlaceholderData}
/>
</div>
);
}
Infinite Queries
For infinite scrolling (activity feeds, comment threads):
function useActivityFeed(projectId: string) {
return useInfiniteQuery({
queryKey: ["projects", "detail", projectId, "activity"],
queryFn: ({ pageParam }) =>
AppRuntime.runPromise(
Effect.gen(function* () {
const repo = yield* ActivityRepository;
return yield* repo.list({ projectId, cursor: pageParam, limit: 20 });
})
),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
}
function ActivityFeed({ projectId }: { projectId: string }) {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useActivityFeed(projectId);
const allActivities = data?.pages.flatMap((p) => p.items) ?? [];
return (
<div>
{allActivities.map((activity) => (
<ActivityItem key={activity.id} activity={activity} />
))}
{hasNextPage && (
<Button
variant="ghost"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? "Loading..." : "Load More"}
</Button>
)}
</div>
);
}
Error Handling in Queries
function ProjectDetail({ projectId }: { projectId: string }) {
const { data, error, isLoading } = useQuery(projectDetailOptions(projectId));
if (isLoading) return <ProjectDetailSkeleton />;
if (error) {
// Error from Effect.runPromise preserves the typed error
if (error instanceof ProjectNotFoundError) {
return <EmptyState title="Project not found" description="This project may have been deleted." />;
}
return <ErrorState error={error} onRetry={() => queryClient.invalidateQueries({ queryKey: projectKeys.detail(projectId) })} />;
}
return <ProjectDetailView project={data} />;
}
Summary
- ✅ Query key factories centralize key creation and prevent typos
- ✅ Query options factories enable reuse between hooks and route loaders
- ✅ Effect integration through
AppRuntime.runPromiseinqueryFn - ✅ Optimistic updates provide instant UI feedback with rollback on error
- ✅ Pagination with
keepPreviousDatafor smooth transitions - ✅ Infinite queries for scrollable lists with cursor-based pagination
- ✅ Smart invalidation — invalidate by key prefix for granular cache control