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


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

  1. Start with the basics: Don't over-engineer. Start with simple queries and add complexity as needed.

  2. Use TypeScript: Proper typing makes the development experience much better and catches errors early.

  3. Leverage DevTools: React Query DevTools are invaluable for debugging and understanding your cache.

  4. Think about cache keys: Well-designed cache keys make invalidation and updates much easier.

  5. 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:

🔗 View the React Query hooks

🔗 See the query client setup

🔗 Check out the components


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.

Continue Exploring

Related reading and implementations from this site.

Comments

Have thoughts on this post? Join the discussion below!