React Dashboard with TanStack Query: Production Patterns
This article ties together the entire series: you will build a complete, production-grade dashboard that demonstrates every TanStack Query pattern learned so far. The dashboard fetches user profile, analytics, and notifications simultaneously; handles partial failures; refetches on window focus; allows users to update settings optimistically; and keeps data fresh in the background. The result is a responsive, reliable app that treats network uncertainty as a first-class concern.
I built this example dashboard from scratch two years ago, and it changed my understanding of user experience. Every pattern in this article came from decisions made during that build: how to show partial data, when to refetch, how to handle network failures gracefully. If you implement this dashboard, you will understand TanStack Query deeply enough to use it confidently in any app.
Architecture Overview
The dashboard fetches three independent data sources: a user profile, analytics metrics, and notifications. All three are fetched in parallel, with staggered refetch intervals and error handling. Here is the component tree:
<Dashboard>
├── <Header (user profile)>
├── <AnalyticsGrid (metrics)>
└── <NotificationCenter (notifications + refetch)>
Each component is independent and can fail without breaking the others.
The Complete Dashboard Component
Here is the full implementation:
import { useQueries, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import React from 'react';
// ============ Query Hooks ============
function useDashboardQueries(userId) {
return useQueries({
queries: [
{
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
},
staleTime: 1000 * 60 * 5, // 5 minutes
},
{
queryKey: ['analytics', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}/analytics`);
if (!res.ok) throw new Error('Failed to fetch analytics');
return res.json();
},
staleTime: 1000 * 60 * 2, // 2 minutes
},
{
queryKey: ['notifications', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}/notifications`);
if (!res.ok) throw new Error('Failed to fetch notifications');
return res.json();
},
staleTime: 1000 * 30, // 30 seconds
refetchInterval: 1000 * 60, // Poll every minute
refetchIntervalInBackground: true,
},
],
});
}
function useUpdateUserSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ userId, settings }) => {
const res = await fetch(`/api/users/${userId}/settings`, {
method: 'PATCH',
body: JSON.stringify(settings),
});
if (!res.ok) throw new Error('Failed to update settings');
return res.json();
},
onMutate: async ({ userId, settings }) => {
// Optimistically update user profile
await queryClient.cancelQueries({ queryKey: ['user', userId] });
const previousUser = queryClient.getQueryData(['user', userId]);
queryClient.setQueryData(['user', userId], (old) => ({
...old,
settings: { ...old.settings, ...settings },
}));
return { previousUser };
},
onError: (error, variables, context) => {
if (context?.previousUser) {
queryClient.setQueryData(['user', variables.userId], context.previousUser);
}
},
onSuccess: ({ userId }) => {
queryClient.invalidateQueries({
queryKey: ['user', userId],
exact: true,
});
},
});
}
// ============ Component: Header ============
function Header({ userQuery, settingsLoading }) {
const { mutate: updateSettings } = useUpdateUserSettings();
if (userQuery.isPending) {
return <HeaderSkeleton />;
}
if (userQuery.isError) {
return (
<div style={{ padding: '20px', color: 'red', border: '1px solid red' }}>
<p>Failed to load profile: {userQuery.error.message}</p>
<button onClick={() => userQuery.refetch()}>Retry</button>
</div>
);
}
const user = userQuery.data;
return (
<header
style={{
padding: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: '1px solid #ccc',
}}
>
<div>
<h1>{user.name}</h1>
<p style={{ color: 'gray' }}>{user.email}</p>
</div>
<div style={{ textAlign: 'right' }}>
<label>
<input
type="checkbox"
checked={user.settings?.notificationsEnabled ?? true}
onChange={(e) =>
updateSettings({
userId: user.id,
settings: { notificationsEnabled: e.target.checked },
})
}
disabled={settingsLoading}
/>
Notifications
</label>
{userQuery.isFetching && !userQuery.isPending && (
<p style={{ fontSize: '12px', color: 'gray' }}>Syncing...</p>
)}
</div>
</header>
);
}
function HeaderSkeleton() {
return (
<header
style={{
padding: '20px',
borderBottom: '1px solid #ccc',
backgroundColor: '#f0f0f0',
height: '80px',
}}
/>
);
}
// ============ Component: Analytics Grid ============
function AnalyticsGrid({ analyticsQuery }) {
if (analyticsQuery.isPending) {
return <AnalyticsSkeleton />;
}
if (analyticsQuery.isError) {
return (
<div style={{ padding: '20px', color: 'red' }}>
Failed to load analytics: {analyticsQuery.error.message}
</div>
);
}
const analytics = analyticsQuery.data;
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '20px',
padding: '20px',
}}
>
<MetricCard label="Pageviews" value={analytics.pageviews} />
<MetricCard label="Unique Users" value={analytics.uniqueUsers} />
<MetricCard label="Conversion Rate" value={`${analytics.conversionRate}%`} />
<MetricCard label="Avg Session" value={`${analytics.avgSessionTime}s`} />
{analyticsQuery.isFetching && !analyticsQuery.isPending && (
<p style={{ fontSize: '12px', color: 'gray', gridColumn: '1 / -1' }}>
Updating metrics...
</p>
)}
</div>
);
}
function MetricCard({ label, value }) {
return (
<div
style={{
padding: '20px',
border: '1px solid #ddd',
borderRadius: '8px',
backgroundColor: '#f9f9f9',
}}
>
<p style={{ margin: '0 0 10px 0', color: 'gray', fontSize: '14px' }}>
{label}
</p>
<p style={{ margin: 0, fontSize: '24px', fontWeight: 'bold' }}>{value}</p>
</div>
);
}
function AnalyticsSkeleton() {
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '20px',
padding: '20px',
}}
>
{[...Array(4)].map((_, i) => (
<div
key={i}
style={{
padding: '20px',
border: '1px solid #ddd',
borderRadius: '8px',
backgroundColor: '#f0f0f0',
height: '100px',
}}
/>
))}
</div>
);
}
// ============ Component: Notification Center ============
function NotificationCenter({ notificationsQuery, userId }) {
const queryClient = useQueryClient();
const markAsRead = async (notificationId) => {
// Optimistically mark as read
const previousNotifications = queryClient.getQueryData(['notifications', userId]);
queryClient.setQueryData(['notifications', userId], (old) =>
old.map((n) =>
n.id === notificationId ? { ...n, read: true } : n
)
);
try {
await fetch(`/api/notifications/${notificationId}/read`, { method: 'POST' });
} catch (error) {
// Rollback on error
queryClient.setQueryData(['notifications', userId], previousNotifications);
}
};
if (notificationsQuery.isPending) {
return <p style={{ padding: '20px' }}>Loading notifications...</p>;
}
if (notificationsQuery.isError) {
return (
<div style={{ padding: '20px', color: 'red' }}>
Failed to load notifications
</div>
);
}
const notifications = notificationsQuery.data || [];
const unreadCount = notifications.filter((n) => !n.read).length;
return (
<aside
style={{
position: 'fixed',
right: '20px',
top: '100px',
width: '300px',
border: '1px solid #ddd',
borderRadius: '8px',
backgroundColor: 'white',
maxHeight: '400px',
overflowY: 'auto',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
>
<div style={{ padding: '15px', borderBottom: '1px solid #eee' }}>
<h3 style={{ margin: 0 }}>Notifications</h3>
<p style={{ margin: '5px 0 0 0', fontSize: '12px', color: 'gray' }}>
{unreadCount} unread
</p>
</div>
{notifications.length === 0 ? (
<p style={{ padding: '20px', textAlign: 'center', color: 'gray' }}>
No notifications
</p>
) : (
notifications.map((notification) => (
<div
key={notification.id}
style={{
padding: '15px',
borderBottom: '1px solid #eee',
backgroundColor: notification.read ? 'white' : '#f0f8ff',
cursor: 'pointer',
}}
onClick={() => markAsRead(notification.id)}
>
<p style={{ margin: 0, fontWeight: notification.read ? 'normal' : 'bold' }}>
{notification.title}
</p>
<p style={{ margin: '5px 0 0 0', fontSize: '12px', color: 'gray' }}>
{new Date(notification.createdAt).toLocaleString()}
</p>
</div>
))
)}
{notificationsQuery.isFetching && (
<p style={{ padding: '10px', fontSize: '12px', color: 'gray', textAlign: 'center' }}>
Updating...
</p>
)}
</aside>
);
}
// ============ Main Dashboard ============
export default function Dashboard({ userId }) {
const [queryResults] = useDashboardQueries(userId);
const userQuery = queryResults[0];
const analyticsQuery = queryResults[1];
const notificationsQuery = queryResults[2];
const { mutate: updateSettings, isPending: settingsLoading } =
useUpdateUserSettings();
// Show basic layout even if some queries fail
const isAnyLoading =
userQuery.isPending ||
analyticsQuery.isPending ||
notificationsQuery.isPending;
return (
<div>
<Header userQuery={userQuery} settingsLoading={settingsLoading} />
<main>
<h2 style={{ padding: '20px', paddingBottom: 0 }}>Analytics</h2>
<AnalyticsGrid analyticsQuery={analyticsQuery} />
</main>
<NotificationCenter notificationsQuery={notificationsQuery} userId={userId} />
</div>
);
}
Key Patterns Demonstrated
Parallel Queries: All three data sources fetch simultaneously via useQueries, so the dashboard loads faster than sequential fetches.
Partial Failures: Each component handles its own isPending and isError states. If analytics fails, the user still sees the profile and notifications.
Optimistic Updates: Toggling notifications updates the UI instantly; if the server call fails, the old value is restored.
Background Refresh: Notifications poll every minute, even if the browser tab is hidden (controlled by refetchIntervalInBackground: true).
Window Focus Refetch: By default, refetchOnWindowFocus: true means when the user switches back to the dashboard, stale data is refetched in the background.
Loading States: The header shows a skeleton on first load only (isPending); when refetching in the background, a subtle "Syncing..." message appears (isFetching && !isPending).
Error Recovery: Each component has a fallback that shows the error and a retry button.
Configuration Best Practices
Set global defaults on the QueryClient:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
gcTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: true,
refetchOnReconnect: true,
retry: 2,
retryDelay: (attemptIndex) => {
return Math.min(1000 * 2 ** attemptIndex, 30000);
},
},
mutations: {
retry: 1,
},
},
});
export function App() {
return (
<QueryClientProvider client={queryClient}>
<Dashboard userId={currentUserId} />
</QueryClientProvider>
);
}
These defaults apply to all queries and mutations, avoiding repetition while allowing per-query overrides.
Testing the Dashboard
Use @tanstack/react-query/devtools to inspect cache during development:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<Dashboard />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
The DevTools show all queries, their state, cache entries, and a request timeline. Invaluable for debugging cache issues.
Production Checklist
Before shipping:
- All queries have appropriate
staleTimebased on data volatility - Mutations invalidate affected queries to keep cache consistent
- Error boundaries wrap components to gracefully handle unexpected errors
- Optimistic updates have rollback logic on mutation failure
- Loading states distinguish between first load (
isLoading) and background refetch (isFetching) - Sensitive data has
gcTime: 0so it is not persisted in memory - Query keys follow a consistent naming convention (factory pattern preferred)
- Mutations have
retrylogic with exponential backoff for transient errors - Tests mock fetch calls and validate cache behavior
Key Takeaways
- Build dashboards with
useQueriesfor parallel fetching; each component handles its own failure state. - Use
staleTimeto control refetch frequency per query type; shorter for volatile data, longer for stable. - Combine optimistic updates with error rollback for instant, reliable UIs.
- Set global defaults on
QueryClientfor consistency; override per-query when needed. - Distinguish
isLoading(skeleton screens) fromisFetching(subtle refresh indicators). - Invalidate only affected queries after mutations; broad invalidation is wasteful.
- Leverage
refetchOnWindowFocusandrefetchOnReconnectfor automatic synchronization.
Frequently Asked Questions
How do I handle WebSocket updates in this dashboard?
Subscribe to WebSocket messages and use queryClient.setQueryData() to update cache directly:
React.useEffect(() => {
const socket = new WebSocket('/ws');
socket.onmessage = (event) => {
const { type, data } = JSON.parse(event.data);
if (type === 'notification_new') {
queryClient.setQueryData(['notifications', userId], (old) => [
data,
...(old || []),
]);
}
};
return () => socket.close();
}, [userId, queryClient]);
Should I use this exact structure for all dashboards?
No, adapt it to your data model. The patterns (parallel queries, optimistic updates, cache invalidation) are universal; the component tree is specific to this use case.
How do I add infinite scroll to the notifications panel?
Use useInfiniteQuery instead of useQuery for notifications, and add fetchNextPage() to the panel footer.
What if the user has poor connectivity?
Increase retry and retryDelay for slower networks. Show error states gracefully. Combine with refetchOnReconnect: true so data syncs when connectivity returns.
Can I persist the cache to localStorage?
Yes, use @tanstack/react-query-persist-client plugin for cache persistence across page reloads.