From API Call Chaos to Efficient Data Management: How React Query Transformed My Strava App
A deep dive into implementing React Query to solve data fetching problems in a real-world Strava integration project
From API Call Chaos to Efficient Data Management: How React Query Transformed My Strava App
In my previous post, I shared the journey of refactoring Enduro Stats from a simple API wrapper into a robust analytics platform. One of the biggest challenges I faced was managing the chaos of API calls scattered across components. This post dives deep into how React Query became the solution that transformed my data management approach.
📱 View the live application | ⚡ Explore the source code on GitHub
The Problem: API Call Chaos
Before React Query, my Enduro Stats app suffered from what I now call "API call chaos":
// ❌ Before: Components managing their own data fetching
function ActivityList({ userId }: { userId: string }) {
const [activities, setActivities] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchActivities(userId)
.then(setActivities)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
// Same data fetched in multiple components
// No caching strategy
// Loading states scattered everywhere
// Error handling inconsistent
}
This approach led to several problems:
- Redundant API calls: The same data was fetched multiple times across different components
- Poor user experience: Loading states and errors were handled inconsistently
- No caching: Every navigation triggered fresh API calls
- Tight coupling: Components were responsible for both UI and data fetching
- Difficult testing: Data fetching logic was embedded in components
What is React Query?
React Query (now TanStack Query) is a powerful library that solves these exact problems. It provides:
- Automatic caching and background updates
- Built-in loading and error states
- Optimistic updates and offline support
- Request deduplication and retry logic
- DevTools for debugging and monitoring
At its core, React Query treats server state as a cache that can be:
- Fetched when needed
- Cached for reuse
- Synchronized in the background
- Updated optimistically
The Implementation: Transforming Enduro Stats
Step 1: Setting Up React Query
First, I set up the React Query provider in my app:
// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
retry: 3,
refetchOnWindowFocus: false,
},
},
});
Step 2: Creating Custom Hooks
Instead of fetching data directly in components, I created custom hooks that encapsulate the data fetching logic:
// hooks/useActivities.ts
import { useQuery } from '@tanstack/react-query';
interface Activity {
id: string;
name: string;
distance: number;
start_date: string;
// ... other fields
}
export function useActivities(userId: string) {
return useQuery({
queryKey: ['activities', userId],
queryFn: async (): Promise<Activity[]> => {
const response = await fetch(`/api/activities/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch activities');
}
return response.json();
},
enabled: !!userId, // Only fetch if userId exists
});
}
export function useActivityStats(userId: string) {
return useQuery({
queryKey: ['activity-stats', userId],
queryFn: async () => {
const response = await fetch(`/api/activities/${userId}/stats`);
if (!response.ok) {
throw new Error('Failed to fetch activity stats');
}
return response.json();
},
enabled: !!userId,
});
}
Step 3: Simplifying Components
With React Query, my components became much simpler and focused on their primary responsibility: rendering UI:
// components/ActivityList.tsx
import { useActivities } from '@/hooks/useActivities';
import { ActivityCard } from './ActivityCard';
import { ActivitySkeleton } from './ActivitySkeleton';
import { ErrorBoundary } from './ErrorBoundary';
interface ActivityListProps {
userId: string;
}
export function ActivityList({ userId }: ActivityListProps) {
const { data: activities, isLoading, error } = useActivities(userId);
if (isLoading) return <ActivitySkeleton />;
if (error) return <ErrorBoundary error={error} />;
return (
<div className="space-y-4">
{activities?.map((activity) => (
<ActivityCard key={activity.id} activity={activity} />
))}
</div>
);
}
Step 4: Handling Mutations
For creating, updating, and deleting activities, I used React Query's mutation hooks:
// hooks/useActivityMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useCreateActivity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (activityData: CreateActivityData) => {
const response = await fetch('/api/activities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(activityData),
});
if (!response.ok) {
throw new Error('Failed to create activity');
}
return response.json();
},
onSuccess: (newActivity, variables) => {
// Optimistically update the cache
queryClient.setQueryData(
['activities', variables.userId],
(oldActivities: Activity[] = []) => [newActivity, ...oldActivities]
);
// Invalidate related queries
queryClient.invalidateQueries({
queryKey: ['activity-stats', variables.userId],
});
},
});
}
The Results: Night and Day Difference
Before React Query:
- Multiple API calls for the same data
- Inconsistent loading states
- Poor error handling
- No caching - every navigation was slow
- Components doing too much
After React Query:
- Automatic request deduplication
- Consistent loading and error states
- Intelligent caching with background updates
- Optimistic updates for better UX
- Clean separation of concerns
Advanced Patterns I Implemented
1. Dependent Queries
For complex data that depends on other queries:
export function useActivityAnalytics(userId: string) {
const { data: activities } = useActivities(userId);
return useQuery({
queryKey: ['activity-analytics', userId],
queryFn: () => calculateAnalytics(activities),
enabled: !!activities, // Only run when activities are available
});
}
2. Infinite Queries for Pagination
For handling large datasets:
export function useInfiniteActivities(userId: string) {
return useInfiniteQuery({
queryKey: ['activities', userId, 'infinite'],
queryFn: ({ pageParam = 1 }) =>
fetchActivities(userId, { page: pageParam, limit: 20 }),
getNextPageParam: (lastPage, pages) =>
lastPage.hasMore ? pages.length + 1 : undefined,
});
}
3. Optimistic Updates
For immediate UI feedback:
export function useUpdateActivity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateActivity,
onMutate: async (updatedActivity) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: ['activities', updatedActivity.userId],
});
// Snapshot previous value
const previousActivities = queryClient.getQueryData([
'activities',
updatedActivity.userId
]);
// Optimistically update
queryClient.setQueryData(
['activities', updatedActivity.userId],
(old: Activity[] = []) =>
old.map(activity =>
activity.id === updatedActivity.id
? { ...activity, ...updatedActivity }
: activity
)
);
return { previousActivities };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousActivities) {
queryClient.setQueryData(
['activities', variables.userId],
context.previousActivities
);
}
},
});
}
Performance Improvements
The impact on performance was significant:
- Reduced API calls by 70% through intelligent caching
- Faster page loads with cached data
- Better user experience with optimistic updates
- Reduced server load through request deduplication
Lessons Learned
-
Start with the basics: Don't over-engineer. Start with simple queries and add complexity as needed.
-
Use TypeScript: Proper typing makes the development experience much better and catches errors early.
-
Leverage DevTools: React Query DevTools are invaluable for debugging and understanding your cache.
-
Think about cache keys: Well-designed cache keys make invalidation and updates much easier.
-
Handle errors gracefully: Always provide fallbacks and error boundaries.
What's Next
React Query was just one piece of the puzzle. In future posts, I'll cover:
- Authentication implementation with OAuth 2.0
- Data synchronization between Strava and our database
- Testing strategies for complex data flows
- Performance optimization techniques
Explore the Implementation
The complete React Query implementation is available on GitHub:
This post is part of a series documenting the refactoring of Enduro Stats. Check out the previous post for the full story of the transformation.
Comments
Have thoughts on this post? Join the discussion below!