Skip to main content

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.runPromise in queryFn
  • Optimistic updates provide instant UI feedback with rollback on error
  • Pagination with keepPreviousData for smooth transitions
  • Infinite queries for scrollable lists with cursor-based pagination
  • Smart invalidation — invalidate by key prefix for granular cache control