All skills
Skillintermediate

Data Fetching Patterns

Modern data fetching using TanStack Query with Suspense boundaries, cache-first strategies, and centralized API services.

Claude Code Knowledge Pack7/10/2026

Overview

Data Fetching Patterns

Modern data fetching using TanStack Query with Suspense boundaries, cache-first strategies, and centralized API services.


PRIMARY PATTERN: useSuspenseQuery

Why useSuspenseQuery?

For all new components, use useSuspenseQuery instead of regular useQuery:

Benefits:

  • No isLoading checks needed
  • Integrates with Suspense boundaries
  • Cleaner component code
  • Consistent loading UX
  • Better error handling with error boundaries

Basic Pattern


    // No isLoading - Suspense handles it!
    const { data } = useSuspenseQuery({
        queryKey: ['myEntity', id],
        queryFn: () => myFeatureApi.getEntity(id),
    });

    // data is ALWAYS defined here (not undefined | Data)
    return <div>{data.name}</div>;
};

// Wrap in Suspense boundary

    

useSuspenseQuery vs useQuery

FeatureuseSuspenseQueryuseQuery
Loading stateHandled by SuspenseManual isLoading check
Data typeAlways defined`Data \
Use withSuspense boundariesTraditional components
Recommended forNEW componentsLegacy code only
Error handlingError boundariesManual error state

When to use regular useQuery:

  • Maintaining legacy code
  • Very simple cases without Suspense
  • Polling with background updates

For new components: Always prefer useSuspenseQuery


Cache-First Strategy

Cache-First Pattern Example

Smart caching reduces API calls by checking React Query cache first:


    const queryClient = useQueryClient();

    return useSuspenseQuery({
        queryKey: ['post', postId],
        queryFn: async () => {
            // Strategy 1: Try to get from list cache first
            const cachedListData = queryClient.getQueryData<{ posts: Post[] }>([
                'posts',
                'list'
            ]);

            if (cachedListData?.posts) {
                const cachedPost = cachedListData.posts.find(
                    (post) => post.id === postId
                );

                if (cachedPost) {
                    return cachedPost;  // Return from cache!
                }
            }

            // Strategy 2: Not in cache, fetch from API
            return postApi.getPost(postId);
        },
        staleTime: 5 * 60 * 1000,      // Consider fresh for 5 minutes
        gcTime: 10 * 60 * 1000,         // Keep in cache for 10 minutes
        refetchOnWindowFocus: false,    // Don't refetch on focus
    });
}

Key Points:

  • Check grid/list cache before API call
  • Avoids redundant requests
  • staleTime: How long data is considered fresh
  • gcTime: How long unused data stays in cache
  • refetchOnWindowFocus: false: User preference

Parallel Data Fetching

useSuspenseQueries

When fetching multiple independent resources:


    const [userQuery, settingsQuery, preferencesQuery] = useSuspenseQueries({
        queries: [
            {
                queryKey: ['user'],
                queryFn: () => userApi.getCurrentUser(),
            },
            {
                queryKey: ['settings'],
                queryFn: () => settingsApi.getSettings(),
            },
            {
                queryKey: ['preferences'],
                queryFn: () => preferencesApi.getPreferences(),
            },
        ],
    });

    // All data available, Suspense handles loading
    const user = userQuery.data;
    const settings = settingsQuery.data;
    const preferences = preferencesQuery.data;

    return ;
};

Benefits:

  • All queries in parallel
  • Single Suspense boundary
  • Type-safe results

Query Keys Organization

Naming Convention

// Entity list
['entities', blogId]
['entities', blogId, 'summary']    // With view mode
['entities', blogId, 'flat']

// Single entity
['entity', blogId, entityId]

// Related data
['entity', entityId, 'history']
['entity', entityId, 'comments']

// User-specific
['user', userId, 'profile']
['user', userId, 'permissions']

Rules:

  • Start with entity name (plural for lists, singular for one)
  • Include IDs for specificity
  • Add view mode / relationship at end
  • Consistent across app

Query Key Examples

// From useSuspensePost.ts
queryKey: ['post', blogId, postId]
queryKey: ['posts-v2', blogId, 'summary']

// Invalidation patterns
queryClient.invalidateQueries({ queryKey: ['post', blogId] });  // All posts for form
queryClient.invalidateQueries({ queryKey: ['post'] });          // All posts

API Service Layer Pattern

File Structure

Create centralized API service per feature:

features/
  my-feature/
    api/
      myFeatureApi.ts    # Service layer

Service Pattern (from postApi.ts)

/**
 * Centralized API service for my-feature operations
 * Uses apiClient for consistent error handling
 */

    /**
     * Fetch a single entity
     */
    getEntity: async (blogId: number, entityId: number): Promise => {
        const { data } = await apiClient.get(
            `/blog/entities/${blogId}/${entityId}`
        );
        return data;
    },

    /**
     * Fetch all entities for a form
     */
    getEntities: async (blogId: number, view: 'summary' | 'flat'): Promise<MyEntity[]> => {
        const { data } = await apiClient.get(
            `/blog/entities/${blogId}`,
            { params: { view } }
        );
        return data.rows;
    },

    /**
     * Update entity
     */
    updateEntity: async (
        blogId: number,
        entityId: number,
        payload: UpdatePayload
    ): Promise => {
        const { data } = await apiClient.put(
            `/blog/entities/${blogId}/${entityId}`,
            payload
        );
        return data;
    },

    /**
     * Delete entity
     */
    deleteEntity: async (blogId: number, entityId: number): Promise<void> => {
        await apiClient.delete(`/blog/entities/${blogId}/${entityId}`);
    },
};

Key Points:

  • Export single object with methods
  • Use apiClient (axios instance from @/lib/apiClient)
  • Type-safe parameters and returns
  • JSDoc comments for each method
  • Centralized error handling (apiClient handles it)

Route Format Rules (IMPORTANT)

Correct Format

// ✅ CORRECT - Direct service path
await apiClient.get('/blog/posts/123');
await apiClient.post('/projects/create', data);
await apiClient.put('/users/update/456', updates);
await apiClient.get('/email/templates');

// ❌ WRONG - Do NOT add /api/ prefix
await apiClient.get('/api/blog/posts/123');  // WRONG!
await apiClient.post('/api/projects/create', data); // WRONG!

Microservice Routing:

  • Form service: /blog/*
  • Projects service: /projects/*
  • Email service: /email/*
  • Users service: /users/*

Why: API routing is handled by proxy configuration, no /api/ prefix needed.


Mutations

Basic Mutation Pattern


    const queryClient = useQueryClient();
    const { showSuccess, showError } = useMuiSnackbar();

    const updateMutation = useMutation({
        mutationFn: (payload: UpdatePayload) =>
            myFeatureApi.updateEntity(blogId, entityId, payload),

        onSuccess: () => {
            // Invalidate and refetch
            queryClient.invalidateQueries({
                queryKey: ['entity', blogId, entityId]
            });
            showSuccess('Entity updated successfully');
        },

        onError: (error) => {
            showError('Failed to update entity');
            console.error('Update error:', error);
        },
    });

    const handleUpdate = () => {
        updateMutation.mutate({ name: 'New Name' });
    };

    return (
        
            {updateMutation.isPending ? 'Updating...' : 'Update'}
        
    );
};

Optimistic Updates

const updateMutation = useMutation({
    mutationFn: (payload) => myFeatureApi.update(id, payload),

    // Optimistic update
    onMutate: async (newData) => {
        // Cancel outgoing refetches
        await queryClient.cancelQueries({ queryKey: ['entity', id] });

        // Snapshot current value
        const previousData = queryClient.getQueryData(['entity', id]);

        // Optimistically update
        queryClient.setQueryData(['entity', id], (old) => ({
            ...old,
            ...newData,
        }));

        // Return rollback function
        return { previousData };
    },

    // Rollback on error
    onError: (err, newData, context) => {
        queryClient.setQueryData(['entity', id], context.previousData);
        showError('Update failed');
    },

    // Refetch after success or error
    onSettled: () => {
        queryClient.invalidateQueries({ queryKey: ['entity', id] });
    },
});

Advanced Query Patterns

Prefetching


    const queryClient = useQueryClient();

    return (blogId: number, entityId: number) => {
        return queryClient.prefetchQuery({
            queryKey: ['entity', blogId, entityId],
            queryFn: () => myFeatureApi.getEntity(blogId, entityId),
            staleTime: 5 * 60 * 1000,
        });
    };
}

// Usage: Prefetch on hover
<div onMouseEnter={() => prefetch(blogId, id)}>
    View
</div>

Cache Access Without Fetching


    const queryClient = useQueryClient();

    // Get from cache, don't fetch if missing
    const directCache = queryClient.getQueryData(['entity', blogId, entityId]);

    if (directCache) return directCache;

    // Try grid cache
    const gridCache = queryClient.getQueryData<{ rows: MyEntity[] }>(['entities-v2', blogId]);

    return gridCache?.rows.find(row => row.id === entityId);
}

Dependent Queries

// Fetch user first, then user's settings
const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => userApi.getUser(userId),
});

const { data: settings } = useSuspenseQuery({
    queryKey: ['user', userId, 'settings'],
    queryFn: () => settingsApi.getUserSettings(user.id),
    // Automatically waits for user to load due to Suspense
});

API Client Configuration

Using apiClient


// apiClient is a configured axios instance
// Automatically includes:
// - Base URL configuration
// - Cookie-based authentication
// - Error interceptors
// - Response transformers

Do NOT create new axios instances - use apiClient for consistency.


Error Handling in Queries

onError Callback


const { showError } = useMuiSnackbar();

const { data } = useSuspenseQuery({
    queryKey: ['entity', id],
    queryFn: () => myFeatureApi.getEntity(id),

    // Handle errors
    onError: (error) => {
        showError('Failed to load entity');
        console.error('Load error:', error);
    },
});

Error Boundaries

Combine with Error Boundaries for comprehensive error handling:


}
    onError={(error) => console.error(error)}
>
    
        
    


Complete Examples

Example 1: Simple Entity Fetch


interface UserProfileProps {
    userId: string;
}

    const { data: user } = useSuspenseQuery({
        queryKey: ['user', userId],
        queryFn: () => userApi.getUser(userId),
        staleTime: 5 * 60 * 1000,
    });

    return (
        
            {user.name}
            {user.email}
        
    );
};

// Usage with Suspense

    

Example 2: Cache-First Strategy


/**
 * Hook with cache-first strategy
 * Checks grid cache before API call
 */

    const queryClient = useQueryClient();

    return useSuspenseQuery<Post, Error>({
        queryKey: ['post', blogId, postId],
        queryFn: async () => {
            // 1. Check grid cache first
            const gridCache = queryClient.getQueryData<{ rows: Post[] }>([
                'posts-v2',
                blogId,
                'summary'
            ]) || queryClient.getQueryData<{ rows: Post[] }>([
                'posts-v2',
                blogId,
                'flat'
            ]);

            if (gridCache?.rows) {
                const cached = gridCache.rows.find(row => row.S_ID === postId);
                if (cached) {
                    return cached;  // Reuse grid data
                }
            }

            // 2. Not in cache, fetch directly
            return postApi.getPost(blogId, postId);
        },
        staleTime: 5 * 60 * 1000,
        gcTime: 10 * 60 * 1000,
        refetchOnWindowFocus: false,
    });
}

Benefits:

  • Avoids duplicate API calls
  • Instant data if already loaded
  • Falls back to API if not cached

Example 3: Parallel Fetching


    const [statsQuery, projectsQuery, notificationsQuery] = use