Logo jitendra.dev
Published on

Boost React Performance: React Query Data Fetching & Mutations

Authors

Table of contents:

Boost React Performance: React Query Data Fetching & Mutations

This comprehensive guide covers everything you need to know about using React Query (TanStack Query) for data fetching, mutations, and integrating with our custom authentication system.


πŸš€ Introduction to React Query

React Query is a powerful data-fetching library that provides:

  • Caching: Automatic background updates and intelligent caching

  • Synchronization: Keeps your UI in sync with server state

  • Background Updates: Automatically refetches stale data

  • Optimistic Updates: Instant UI updates with rollback on failure

  • Error Handling: Built-in error states and retry logic

Core Concepts

  • Queries: For fetching data (GET requests)
  • Mutations: For modifying data (POST, PUT, DELETE requests)
  • Query Keys: Unique identifiers for cached data
  • Query Functions: Functions that return a Promise with your data

Installation

npm install @tanstack/react-query

Set up the QueryClient in your root component:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  );
}

πŸ“– GET Requests with useQuery

Basic useQuery Structure


import { useQuery } from  '@tanstack/react-query';

import  axios  from  'axios';

const  useExample = () => {
	return  useQuery({
		queryKey: ['unique-key'],
		queryFn:  async () => {
			const  response = await  axios.get('/api/endpoint');
			return  response.data;
		},
		// Optional configurations
		staleTime:  5 * 60 * 1000, // 5 minutes
		cacheTime:  10 * 60 * 1000, // 10 minutes
		refetchOnWindowFocus:  false,
		enabled:  true, // Controls when query runs
	});
};

Query States and Properties


const  MyComponent = () => {
	const {
		data, // The data returned from queryFn
		isLoading, // True if query is loading for the first time
		isFetching, // True whenever query is fetching (including background)
		isError, // True if query encountered an error
		error, // The error object (if any)
		isSuccess, // True if query completed successfully
		refetch, // Function to manually refetch
		isStale, // True if data is considered stale
	} = useQuery({
		queryKey: ['users'],
		queryFn:  fetchUsers,
	});

	if (isLoading) return <div>Loading...</div>;
	if (isError) return <div>Error: {error.message}</div>;
	if (isSuccess) return <div>Data: {JSON.stringify(data)}</div>;
};

Dynamic Query Keys


// βœ… Good: Dynamic query keys for different data

const  useUserProfile = (userId: string) => {
	return  useQuery({
		queryKey: ['user', 'profile', userId],
		queryFn:  async () => {
			const  response = await  axios.get(`/api/users/${userId}`);
			return  response.data;
		},
		enabled: !!userId, // Only fetch when userId exists
	});
};

  

// βœ… Good: Complex query keys

const  usePostsWithFilters = (filters: { category?: string; status?: string }) => {
	return  useQuery({
		queryKey: ['posts', filters],
		queryFn:  async () => {
			const  params = new  URLSearchParams(filters);
			const  response = await  axios.get(`/api/posts?${params}`);
			return  response.data;
		},
	});
};

Conditional Queries


// Only fetch when certain conditions are met
const  useConditionalData = (shouldFetch: boolean, dataId?: string) => {
	return  useQuery({
		queryKey: ['conditional-data', dataId],
		queryFn:  async () => {
			const  response = await  axios.get(`/api/data/${dataId}`);
			return  response.data;
		},
		enabled:  shouldFetch && !!dataId,
	});
};

Dependent Queries


// Query that depends on another query's result
const useUserPosts = (userId: string) => {
  // First query: Get user
  const userQuery = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    enabled: !!userId,
  });

  // Second query: Get user's posts (depends on user data)
  const postsQuery = useQuery({
    queryKey: ['posts', userQuery.data?.id],
    queryFn: () => fetchUserPosts(userQuery.data.id),
    enabled: !!userQuery.data?.id, // Only run when user data is available
  });

  return {
    user: userQuery.data,
    posts: postsQuery.data,
    isLoading: userQuery.isLoading || postsQuery.isLoading,
    error: userQuery.error || postsQuery.error,
  };
}

✏️ POST/PUT/DELETE with useMutation

Basic useMutation Structure

import { useMutation, useQueryClient } from '@tanstack/react-query';

const useCreateUser = () => {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: async (userData: CreateUserData) => {
      const response = await axios.post('/api/users', userData);
      return response.data;
    },
    onSuccess: (data, variables, context) => {
      // Invalidate and refetch related queries
      queryClient.invalidateQueries({ queryKey: ['users'] });
      
      // Or update cache directly
      queryClient.setQueryData(['user', data.id], data);
      
      // Show success message
      toast.success('User created successfully!');
    },
    onError: (error, variables, context) => {
      // Handle error
      toast.error('Failed to create user');
      console.error('Mutation error:', error);
    },
    onSettled: (data, error, variables, context) => {
      // Runs regardless of success/error
      console.log('Mutation completed');
    },
  });
}

Mutation States and Usage

const CreateUserForm = () => {
  const createUserMutation = useCreateUser();
  
  const handleSubmit = (formData) => {
    createUserMutation.mutate(formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" name="name" />
      <input type="email" name="email" />
      
      <button 
        type="submit" 
        disabled={createUserMutation.isPending}
      >
        {createUserMutation.isPending ? 'Creating...' : 'Create User'}
      </button>
      
      {createUserMutation.isError && (
        <div>Error: {createUserMutation.error.message}</div>
      )}
      
      {createUserMutation.isSuccess && (
        <div>User created: {createUserMutation.data.name}</div>
      )}
    </form>
  );
};

Different Mutation Types

// POST: Create new resource
const useCreatePost = () => {
  return useMutation({
    mutationFn: async (postData) => {
      const response = await axios.post('/api/posts', postData);
      return response.data;
    },
  });
};

// PUT: Update entire resource
const useUpdatePost = () => {
  return useMutation({
    mutationFn: async ({ id, data }) => {
      const response = await axios.put(`/api/posts/${id}`, data);
      return response.data;
    },
  });
};

// PATCH: Partial update
const useUpdatePostPartial = () => {
  return useMutation({
    mutationFn: async ({ id, updates }) => {
      const response = await axios.patch(`/api/posts/${id}`, updates);
      return response.data;
    },
  });
};

// DELETE: Remove resource
const useDeletePost = () => {
  return useMutation({
    mutationFn: async (postId) => {
      await axios.delete(`/api/posts/${postId}`);
      return postId;
    },
  });
};

Optimistic Updates

const useUpdatePost = () => {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: async ({ id, updates }) => {
      const response = await axios.patch(`/api/posts/${id}`, updates);
      return response.data;
    },
    onMutate: async ({ id, updates }) => {
      // Cancel any outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['post', id] });
      
      // Snapshot the previous value
      const previousPost = queryClient.getQueryData(['post', id]);
      
      // Optimistically update to the new value
      queryClient.setQueryData(['post', id], (old) => ({
        ...old,
        ...updates,
      }));
      
      // Return a context object with the snapshotted value
      return { previousPost };
    },
    onError: (err, { id }, context) => {
      // If the mutation fails, use the context returned from onMutate to roll back
      queryClient.setQueryData(['post', id], context.previousPost);
    },
    onSettled: (data, error, { id }) => {
      // Always refetch after error or success
      queryClient.invalidateQueries({ queryKey: ['post', id] });
    },
  });
};

πŸ” Authentication Integration

Using Our Custom useAuthenticatedQuery

import { useAuthenticatedQuery } from '@/lib/useAuthenticatedQuery';

// βœ… Simple authenticated query
const useUserProfile = () => {
  return useAuthenticatedQuery({
    queryKey: ['user', 'profile'],
    queryFn: async () => {
      const response = await axios.get('/api/user/profile');
      return response.data;
    },
    staleTime: 5 * 60 * 1000,
  });
  // Automatically includes: enabled: !!localStorage.getItem('token')
};

// βœ… Authenticated query with additional conditions
const useChannelsList = (appId?: string) => {
  return useAuthenticatedQuery({
    queryKey: ['channels', appId],
    queryFn: async () => {
      const response = await axios.get(`/api/channels?appId=${appId}`);
      return response.data;
    },
  }, !!appId); // Additional condition: only fetch when appId exists
  // Final enabled: !!localStorage.getItem('token') && !!appId
};

Overriding to Regular useQuery

import { useQuery } from '@tanstack/react-query';

// βœ… Public data that doesn't require authentication
const usePublicPosts = () => {
  return useQuery({
    queryKey: ['public', 'posts'],
    queryFn: async () => {
      const response = await axios.get('/api/public/posts');
      return response.data;
    },
    staleTime: 10 * 60 * 1000,
  });
};

// βœ… Override authentication for specific cases
const usePostDetails = (postId: string, forcePublic = false) => {
  // Use regular useQuery for public access
  if (forcePublic) {
    return useQuery({
      queryKey: ['post', postId, 'public'],
      queryFn: async () => {
        const response = await axios.get(`/api/public/posts/${postId}`);
        return response.data;
      },
      enabled: !!postId,
    });
  }
  
  // Use authenticated query for private access
  return useAuthenticatedQuery({
    queryKey: ['post', postId, 'private'],
    queryFn: async () => {
      const response = await axios.get(`/api/posts/${postId}`);
      return response.data;
    },
  }, !!postId);
}

Authentication in Mutations

// Mutations don't need authentication wrapper (they're triggered by user actions)
const useCreatePost = () => {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: async (postData) => {
      // Auth token is automatically included in axios config
      const response = await axios.post('/api/posts', postData);
      return response.data;
    },
    onSuccess: () => {
      // Invalidate authenticated queries
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
    onError: (error) => {
      if (error.response?.status === 401) {
        // Handle authentication error
        // Redirect to login or refresh token
      }
    },
  });
};

🎯 Advanced Patterns

Query Factories

// Centralized query key management
export const postKeys = {
  all: ['posts'] as const,
  lists: () => [...postKeys.all, 'list'] as const,
  list: (filters: string) => [...postKeys.lists(), { filters }] as const,
  details: () => [...postKeys.all, 'detail'] as const,
  detail: (id: string) => [...postKeys.details(), id] as const,
};

// Query factory functions
export const postQueries = {
  list: (filters: PostFilters) => ({
    queryKey: postKeys.list(JSON.stringify(filters)),
    queryFn: () => fetchPosts(filters),
  }),
  detail: (id: string) => ({
    queryKey: postKeys.detail(id),
    queryFn: () => fetchPost(id),
  }),
};

// Usage
const usePostList = (filters: PostFilters) => {
  return useAuthenticatedQuery(postQueries.list(filters));
};

Infinite Queries

import { useInfiniteQuery } from '@tanstack/react-query';

const useInfinitePosts = (filters = {}) => {
  return useInfiniteQuery({
    queryKey: ['posts', 'infinite', filters],
    queryFn: async ({ pageParam = 1 }) => {
      const response = await axios.get('/api/posts', {
        params: { ...filters, page: pageParam, limit: 10 }
      });
      return response.data;
    },
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length + 1 : undefined;
    },
    initialPageParam: 1,
  });
};

// Usage in component
const PostList = () => {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfinitePosts();

  return (
    <div>
      {data?.pages.map((group, i) => (
        <div key={i}>
          {group.posts.map(post => (
            <div key={post.id}>{post.title}</div>
          ))}
        </div>
      ))}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading more...' : 'Load More'}
      </button>
    </div>
  );
}

Parallel Queries

// Multiple queries running in parallel
const useDashboardData = (userId: string) => {
  const userQuery = useAuthenticatedQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  }, !!userId);

  const postsQuery = useAuthenticatedQuery({
    queryKey: ['posts', 'user', userId],
    queryFn: () => fetchUserPosts(userId),
  }, !!userId);

  const statsQuery = useAuthenticatedQuery({
    queryKey: ['stats', 'user', userId],
    queryFn: () => fetchUserStats(userId),
  }, !!userId);

  return {
    user: userQuery.data,
    posts: postsQuery.data,
    stats: statsQuery.data,
    isLoading: userQuery.isLoading || postsQuery.isLoading || statsQuery.isLoading,
    error: userQuery.error || postsQuery.error || statsQuery.error,
  };
};

βœ… Best Practices

1. Query Key Conventions

// βœ… Good: Hierarchical and descriptive
['users', 'profile', userId]
['posts', 'list', { status: 'published', category: 'tech' }]
['channels', appId, 'settings']

// ❌ Bad: Flat and unclear
['userProfile123']
['allPosts']
['data']

2. Error Handling

// βœ… Good: Comprehensive error handling
const usePostDetails = (postId: string) => {
  return useAuthenticatedQuery({
    queryKey: ['post', postId],
    queryFn: async () => {
      try {
        const response = await axios.get(`/api/posts/${postId}`);
        return response.data;
      } catch (error) {
        if (error.response?.status === 404) {
          throw new Error('Post not found');
        }
        if (error.response?.status === 403) {
          throw new Error('Access denied');
        }
        throw new Error('Failed to load post');
      }
    },
    retry: (failureCount, error) => {
      // Don't retry on 404 or 403
      if (error.message.includes('not found') || error.message.includes('Access denied')) {
        return false;
      }
      return failureCount < 3;
    },
  }, !!postId);
};

3. Cache Management

// βœ… Good: Strategic cache invalidation
const useUpdatePost = () => {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: updatePost,
    onSuccess: (data, variables) => {
      // Update specific post
      queryClient.setQueryData(['post', variables.id], data);
      
      // Invalidate related lists
      queryClient.invalidateQueries({ 
        queryKey: ['posts', 'list'],
        exact: false // Invalidates all post lists regardless of filters
      });
      
      // Invalidate user's posts if it's their post
      if (data.authorId === getCurrentUserId()) {
        queryClient.invalidateQueries({ 
          queryKey: ['posts', 'user', data.authorId] 
        });
      }
    },
  });
};

4. Loading States

// βœ… Good: Differentiate between loading states
const PostDetails = ({ postId }: { postId: string }) => {
  const { data: post, isLoading, isFetching, error } = usePostDetails(postId);

  if (error) {
    return <ErrorMessage error={error} />;
  }

  if (isLoading) {
    return <FullPageSpinner />; // Initial load
  }

  return (
    <div>
      {isFetching && <TopBarProgress />} {/* Background refetch */}
      <h1>{post?.title}</h1>
      <p>{post?.content}</p>
    </div>
  );
};

⚠️ Common Pitfalls

1. Query Key Dependencies

// ❌ Bad: Missing dependencies in query key
const useUserPosts = (userId: string, filters: PostFilters) => {
  return useQuery({
    queryKey: ['posts', userId], // Missing filters!
    queryFn: () => fetchUserPosts(userId, filters),
  });
};

// βœ… Good: All dependencies included
const useUserPosts = (userId: string, filters: PostFilters) => {
  return useQuery({
    queryKey: ['posts', userId, filters],
    queryFn: () => fetchUserPosts(userId, filters),
  });
};

2. Stale Closures


// ❌ Bad: Stale closure in mutation
const MyComponent = () => {
  const [userId, setUserId] = useState('');
  
  const mutation = useMutation({
    mutationFn: updateUser,
    onSuccess: () => {
      // userId might be stale!
      queryClient.invalidateQueries(['user', userId]);
    },
  });
  
  // ...
};

// βœ… Good: Use mutation variables
const MyComponent = () => {
  const [userId, setUserId] = useState('');
  
  const mutation = useMutation({
    mutationFn: updateUser,
    onSuccess: (data, variables) => {
      // Use variables.userId instead
      queryClient.invalidateQueries(['user', variables.userId]);
    },
  });
  
  // ...
};

3. Over-fetching

// ❌ Bad: Fetching too frequently
const useUserProfile = () => {
  return useQuery({
    queryKey: ['user', 'profile'],
    queryFn: fetchUserProfile,
    staleTime: 0, // Always refetch
    refetchInterval: 1000, // Every second
  });
};

// βœ… Good: Reasonable cache and refetch settings
const useUserProfile = () => {
  return useAuthenticatedQuery({
    queryKey: ['user', 'profile'],
    queryFn: fetchUserProfile,
    staleTime: 5 * 60 * 1000, // 5 minutes
    cacheTime: 10 * 60 * 1000, // 10 minutes
    refetchOnWindowFocus: false,
  });
};

⚑ Performance Optimization

1. Query Optimization

// βœ… Select only needed data
const useUserName = (userId: string) => {
  return useAuthenticatedQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    select: (data) => data.name, // Only return name
    staleTime: 10 * 60 * 1000,
  }, !!userId);
};

// βœ… Structural sharing prevention
const useUserPosts = (userId: string) => {
  return useAuthenticatedQuery({
    queryKey: ['posts', 'user', userId],
    queryFn: () => fetchUserPosts(userId),
    select: useCallback(
      (data) => data.filter(post => post.status === 'published'),
      []
    ),
  }, !!userId);
};

2. Prefetching

// βœ… Prefetch related data
const usePostWithPrefetch = (postId: string) => {
  const queryClient = useQueryClient();
  
  const postQuery = useAuthenticatedQuery({
    queryKey: ['post', postId],
    queryFn: () => fetchPost(postId),
    onSuccess: (data) => {
      // Prefetch author data
      queryClient.prefetchQuery({
        queryKey: ['user', data.authorId],
        queryFn: () => fetchUser(data.authorId),
        staleTime: 5 * 60 * 1000,
      });
    },
  }, !!postId);
  
  return postQuery;
};

3. Background Updates

// βœ… Smart background updates
const useCriticalData = () => {
  return useAuthenticatedQuery({
    queryKey: ['critical', 'data'],
    queryFn: fetchCriticalData,
    staleTime: 30 * 1000, // 30 seconds
    refetchInterval: 60 * 1000, // Refetch every minute
    refetchIntervalInBackground: true, // Continue when tab is inactive
    refetchOnWindowFocus: true, // Refetch when user returns
  });
};

🎯 Summary

When to Use What

Scenario Hook Example
Fetch user data useAuthenticatedQuery User profile, settings
Fetch public data useQuery Public posts, static content
Create/Update data useMutation Form submissions, file uploads
Infinite scrolling useInfiniteQuery Post feeds, search results
Real-time data useAuthenticatedQuery + refetchInterval Dashboard stats

Quick Decision Tree

Need to fetch data?
β”œβ”€β”€ Is it authenticated?
β”‚ β”œβ”€β”€ Yes β†’ useAuthenticatedQuery
β”‚ └── No β†’ useQuery
└── Need to modify data?
└── useMutation

Key Takeaways

  1. Always use useAuthenticatedQuery for protected endpoints
  2. Include all dependencies in query keys
  3. Handle loading and error states appropriately
  4. Use mutations for data modifications
  5. Optimize with caching strategies based on data criticality
  6. Leverage React Query DevTools for debugging

Explore More Topics