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

  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.

Comments

Have thoughts on this post? Join the discussion below!