TanStack Query patterns for this project. Polling with exponential backoff, conditional queries, mutation with toast notifications. Triggers on "useQuery", "useMutation", "tanstack query", "react query", "data fetching".
Frontend uses TanStack Query (React Query) v5 with polling, exponential backoff, and toast notifications via Sonner.
Queries use refetchInterval function for smart polling with increasing delays:
// From frontend/components/dashboard/DigestTrigger.tsx
const { data: executionStatus } = useQuery({
queryKey: ["execution-status", executionArn],
queryFn: async () => {
if (!executionArn) return null;
const res = await fetch(`/api/stepfunctions/status?executionArn=${encodeURIComponent(executionArn)}`);
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to fetch execution status");
}
return res.json();
},
enabled: !!executionArn && pollingEnabled,
refetchInterval: (query) => {
const status = query.state.data?.status;
if (status === "SUCCEEDED" || status === "FAILED" || status === "ABORTED") {
return false; // Stop polling
}
// Exponential backoff: 5s → 10s → 20s → 30s (max)
const attemptCount = query.state.dataUpdateCount || 0;
return Math.min(5000 * 1.5 ** attemptCount, 30000);
},
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
Key points:
refetchInterval receives query object with state.data and state.dataUpdateCountfalse to stop polling based on data conditionbaseDelay * multiplier ** attemptCountMath.min()Use enabled flag to control when queries run:
// From DigestTrigger.tsx
const { data, error } = useQuery({
queryKey: ["execution-status", executionArn],
queryFn: async () => { /* ... */ },
enabled: !!executionArn && pollingEnabled, // Double condition
refetchInterval: isPollingEnabled ? 10000 : false,
});
Pattern:
!!variable ensures boolean for truthy check&&pollingEnabled)refetchInterval: false when disabledStop polling in useEffect when terminal status reached:
// From DigestTrigger.tsx
useEffect(() => {
if (executionStatus?.status === "SUCCEEDED") {
setPollingEnabled(false);
toast.success("Digest generation completed successfully!");
setExecutionArn(null);
} else if (executionStatus?.status === "FAILED") {
setPollingEnabled(false);
toast.error("Digest generation failed. Check logs for details.");
setExecutionArn(null);
} else if (executionStatus?.status === "ABORTED") {
setPollingEnabled(false);
toast.error("Digest generation was aborted.");
setExecutionArn(null);
}
}, [executionStatus?.status]);
Always:
refetchInterval (causes render issues)Add hard timeout to prevent infinite polling:
// From DigestTrigger.tsx
useEffect(() => {
if (executionArn && pollingEnabled) {
const timeout = setTimeout(() => {
toast.warning(
"Execution status check timed out. The process may still be running in the background."
);
setPollingEnabled(false);
setExecutionArn(null);
}, 5 * 60 * 1000); // 5 minutes
return () => clearTimeout(timeout);
}
}, [executionArn, pollingEnabled]);
Pattern:
Handle query errors separately from success:
// From DigestTrigger.tsx
const { data, error: statusError } = useQuery({
queryKey: ["execution-status", executionArn],
queryFn: async () => {
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to fetch execution status");
}
return res.json();
},
retry: 3,
});
useEffect(() => {
if (statusError) {
console.error("Error polling execution status:", statusError);
toast.error("Unable to check execution status. The process may still be running.");
setPollingEnabled(false);
setExecutionArn(null);
}
}, [statusError]);
Pattern:
statusError, fetchError.catch(() => ({})) for safe JSON parsingUse onSuccess and onError callbacks:
// From DigestTrigger.tsx
const triggerMutation = useMutation({
mutationFn: async (options: {
cleanup: boolean;
useStepFunctions: boolean;
historicalMode: boolean;
dateRange?: { start: string; end: string };
}) => {
const res = await fetch("/api/digest/trigger", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(options),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || "Failed to trigger digest");
}
return res.json();
},
onSuccess: (data) => {
if (data.executionArn) {
setExecutionArn(data.executionArn);
setPollingEnabled(true);
toast.success(`Step Functions pipeline started! Execution: ${data.executionName}`);
} else {
toast.success(
cleanup ? "Cleanup digest generation started!" : "Weekly digest generation started!"
);
}
},
onError: () => {
toast.error("Failed to trigger digest generation");
},
});
// Trigger with typed options
const handleTrigger = () => {
triggerMutation.mutate({
cleanup,
useStepFunctions,
historicalMode,
...(historicalMode && startDate && endDate && {
dateRange: { start: startDate, end: endDate },
}),
});
};
Pattern:
mutationFn parameter for safetymutationFn to trigger onErroronSuccessonSuccess if neededStructure query keys for proper caching:
// Simple key
queryKey: ["executions"]
// Key with parameter
queryKey: ["execution-status", executionArn]
Rules:
For lists that need regular updates:
// From frontend/components/dashboard/ExecutionHistory.tsx
const [isPollingEnabled, setIsPollingEnabled] = useState(true);
const { data, isLoading, refetch } = useQuery({
queryKey: ["executions"],
queryFn: async () => {
const res = await fetch("/api/stepfunctions/executions?maxResults=10");
if (!res.ok) throw new Error("Failed to fetch executions");
return res.json();
},
refetchInterval: isPollingEnabled ? 10000 : false, // 10s fixed interval
});
// Cleanup on unmount
useEffect(() => {
return () => {
setIsPollingEnabled(false);
};
}, []);
Pattern:
refetch for user controlUse mutation and query states for UI:
// Button disabled during mutation or polling
<Button
disabled={triggerMutation.isPending || !!executionArn}
onClick={handleTrigger}
>
{triggerMutation.isPending || executionArn ? (
<>
<Loader2 className="animate-spin" />
Processing...
</>
) : (
<>
<Play />
Generate Digest
</>
)}
</Button>
// Show loading state for initial fetch
if (isLoading) {
return <Loader2 className="animate-spin" />;
}
States to check:
isPending - Mutation in flightisLoading - Initial query loadisSuccess - Mutation succeeded (don't use for queries)frontend/components/dashboard/DigestTrigger.tsx - Complex polling with exponential backofffrontend/components/dashboard/ExecutionHistory.tsx - Simple list pollingsonner for toast notificationsrefetchInterval callback (causes render issues)query.state.data without null checksisLoading and isPending (use correct one for context)