Data Fetching Patterns in React: A Comprehensive Guide

In modern React applications, efficient data fetching is crucial for performance and maintainability. This blog post explores an advanced pattern for data fetching that can significantly improve your application's architecture. The Traditional Approach and Its Drawbacks Most React developers are familiar with the standard way of fetching data inside components: function PostComponent() { const { data: post, isPending, isError, error } = useQuery({ queryKey: ['post', postId], queryFn: () => fetchPost(postId), }); if (isPending) return Loading...; if (isError) return Error: {error.message}; return ( {post.title} {post.content} ); } While this approach works, it has several significant drawbacks: 1. Multiple Renders and Performance Issues On initial render, data is undefined The component renders in an undefined state Data fetching begins Component re-renders when data arrives This becomes worse with multiple queries: function PostWithComments() { const postQuery = useQuery({...}); const commentsQuery = useQuery({...}); if (postQuery.isPending || commentsQuery.isPending) return Loading...; if (postQuery.isError || commentsQuery.isError) return Error...; return (...); } Each query triggers its own re-render, leading to performance degradation. 2. Component Complexity Handling loading and error states bloats components with boilerplate code: function ComplexComponent() { const query1 = useQuery({...}); const query2 = useQuery({...}); const query3 = useQuery({...}); // Complicated loading logic const isLoading = query1.isPending || query2.isPending || query3.isPending; // Complicated error handling const error = query1.error?.message || query2.error?.message || query3.error?.message; if (isLoading) return ; if (error) return ; // Actual component logic... } 3. Bundle Loading Delays In modern applications with code splitting: Navigation occurs Route bundle loads asynchronously Component mounts Data fetching begins This creates unnecessary delays in data retrieval. The Solution: Route-Based Data Fetching The recommended pattern moves data fetching to the route level using loader functions. Here's how it works: Implementation with TanStack Router // Route configuration const postRoute = createRoute({ path: '/posts/$postId', component: PostComponent, loader: async ({ params: { postId }, context: { queryClient } }) => { await Promise.all([ queryClient.prefetchQuery({ queryKey: ['post', postId], queryFn: () => fetchPost(postId), }), queryClient.prefetchQuery({ queryKey: ['comments', postId], queryFn: () => fetchComments(postId), }), ]); }, }); Simplified Component function PostComponent() { const { postId } = useParams(); // Data is guaranteed to be available const { data: post } = useSuspenseQuery({ queryKey: ['post', postId], queryFn: () => fetchPost(postId), }); const { data: comments } = useSuspenseQuery({ queryKey: ['comments', postId], queryFn: () => fetchComments(postId), }); return ( {post.title} {post.content} ); } Key Benefits Single Render: Component renders once with all data available Simpler Components: No loading/error state handling Faster Data Fetching: Begins before component bundle loads Centralized Error Handling: Errors handled at route level Handling Loading and Error States With this pattern, loading and error states are managed at the route level: const postRoute = createRoute({ // ...other config pendingComponent: () => , errorComponent: ({ error }) => , }); Partial Prefetching Pattern You don't need to prefetch everything. Here's a hybrid approach: // Prefetch only critical data loader: async ({ params, context }) => { await context.queryClient.prefetchQuery({ queryKey: ['post', params.postId], queryFn: () => fetchPost(params.postId), }); } // Component implementation function PostComponent() { // Critical data (prefetched) const { data: post } = useSuspenseQuery(...); // Secondary data (fetched on mount) const commentsQuery = useQuery({ queryKey: ['comments', post.id], queryFn: () => fetchComments(post.id), }); return ( {/* Post content */} {commentsQuery.isPending ? ( ) : ( )} ); } Advanced Patterns 1. Authentication in Loaders loader: async ({ context }) => { if (!context.auth.isAuthenticated) { throw new Error('Unauthorized'); } // ...data fetching } 2. Dependent Queries loader: async ({ params, context }) => { const user = await context.queryClient.fetchQuery({ queryKey: ['user'], queryFn: fetchUser, }); await context.queryCli

Apr 13, 2025 - 19:23
 0
Data Fetching Patterns in React: A Comprehensive Guide

In modern React applications, efficient data fetching is crucial for performance and maintainability. This blog post explores an advanced pattern for data fetching that can significantly improve your application's architecture.

The Traditional Approach and Its Drawbacks

Most React developers are familiar with the standard way of fetching data inside components:

function PostComponent() {
  const { data: post, isPending, isError, error } = useQuery({
    queryKey: ['post', postId],
    queryFn: () => fetchPost(postId),
  });

  if (isPending) return <div>Loading...</div>;
  if (isError) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </div>
  );
}

While this approach works, it has several significant drawbacks:

1. Multiple Renders and Performance Issues

  • On initial render, data is undefined
  • The component renders in an undefined state
  • Data fetching begins
  • Component re-renders when data arrives

This becomes worse with multiple queries:

function PostWithComments() {
  const postQuery = useQuery({...});
  const commentsQuery = useQuery({...});

  if (postQuery.isPending || commentsQuery.isPending) return <div>Loading...</div>;
  if (postQuery.isError || commentsQuery.isError) return <div>Error...</div>;

  return (...);
}

Each query triggers its own re-render, leading to performance degradation.

2. Component Complexity

Handling loading and error states bloats components with boilerplate code:

function ComplexComponent() {
  const query1 = useQuery({...});
  const query2 = useQuery({...});
  const query3 = useQuery({...});

  // Complicated loading logic
  const isLoading = query1.isPending || query2.isPending || query3.isPending;

  // Complicated error handling
  const error = query1.error?.message || query2.error?.message || query3.error?.message;

  if (isLoading) return <Loader />;
  if (error) return <Error message={error} />;

  // Actual component logic...
}

3. Bundle Loading Delays

In modern applications with code splitting:

  1. Navigation occurs
  2. Route bundle loads asynchronously
  3. Component mounts
  4. Data fetching begins

This creates unnecessary delays in data retrieval.

The Solution: Route-Based Data Fetching

The recommended pattern moves data fetching to the route level using loader functions. Here's how it works:

Implementation with TanStack Router

// Route configuration
const postRoute = createRoute({
  path: '/posts/$postId',
  component: PostComponent,
  loader: async ({ params: { postId }, context: { queryClient } }) => {
    await Promise.all([
      queryClient.prefetchQuery({
        queryKey: ['post', postId],
        queryFn: () => fetchPost(postId),
      }),
      queryClient.prefetchQuery({
        queryKey: ['comments', postId],
        queryFn: () => fetchComments(postId),
      }),
    ]);
  },
});

Simplified Component

function PostComponent() {
  const { postId } = useParams();

  // Data is guaranteed to be available
  const { data: post } = useSuspenseQuery({
    queryKey: ['post', postId],
    queryFn: () => fetchPost(postId),
  });

  const { data: comments } = useSuspenseQuery({
    queryKey: ['comments', postId],
    queryFn: () => fetchComments(postId),
  });

  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <CommentsList comments={comments} />
    </div>
  );
}

Key Benefits

  1. Single Render: Component renders once with all data available
  2. Simpler Components: No loading/error state handling
  3. Faster Data Fetching: Begins before component bundle loads
  4. Centralized Error Handling: Errors handled at route level

Handling Loading and Error States

With this pattern, loading and error states are managed at the route level:

const postRoute = createRoute({
  // ...other config
  pendingComponent: () => <FullPageSpinner />,
  errorComponent: ({ error }) => <FullPageError error={error} />,
});

Partial Prefetching Pattern

You don't need to prefetch everything. Here's a hybrid approach:

// Prefetch only critical data
loader: async ({ params, context }) => {
  await context.queryClient.prefetchQuery({
    queryKey: ['post', params.postId],
    queryFn: () => fetchPost(params.postId),
  });
}

// Component implementation
function PostComponent() {
  // Critical data (prefetched)
  const { data: post } = useSuspenseQuery(...);

  // Secondary data (fetched on mount)
  const commentsQuery = useQuery({
    queryKey: ['comments', post.id],
    queryFn: () => fetchComments(post.id),
  });

  return (
    <div>
      <article>{/* Post content */}</article>
      {commentsQuery.isPending ? (
        <CommentsLoading />
      ) : (
        <CommentsList comments={commentsQuery.data} />
      )}
    </div>
  );
}

Advanced Patterns

1. Authentication in Loaders

loader: async ({ context }) => {
  if (!context.auth.isAuthenticated) {
    throw new Error('Unauthorized');
  }
  // ...data fetching
}

2. Dependent Queries

loader: async ({ params, context }) => {
  const user = await context.queryClient.fetchQuery({
    queryKey: ['user'],
    queryFn: fetchUser,
  });

  await context.queryClient.prefetchQuery({
    queryKey: ['user-posts', user.id],
    queryFn: () => fetchUserPosts(user.id),
  });
}

3. Optimistic Updates

// In your mutation
const mutation = useMutation({
  mutationFn: updatePost,
  onMutate: async (newPost) => {
    await queryClient.cancelQueries(['post', newPost.id]);
    const previousPost = queryClient.getQueryData(['post', newPost.id]);
    queryClient.setQueryData(['post', newPost.id], newPost);
    return { previousPost };
  },
  onError: (err, newPost, context) => {
    queryClient.setQueryData(['post', newPost.id], context.previousPost);
  },
});

Migration Strategy

If you're working with an existing codebase, here's how to migrate incrementally:

  1. Identify critical routes: Start with high-traffic pages
  2. Create loader functions: Move data fetching from components
  3. Use Suspense boundaries: Wrap routes in Suspense
  4. Gradually convert components: Replace useQuery with useSuspenseQuery
  5. Add error boundaries: Implement at route level

Performance Comparison

Metric Traditional Approach Route-Based Fetching
Render cycles 2-3 per query 1
Time to data Bundle + fetch Parallel load
Bundle size Larger components Leaner components
Error handling Per component Centralized

Real-World Example: E-Commerce Product Page

// Route configuration
const productRoute = createRoute({
  path: '/products/$productId',
  component: ProductPage,
  loader: async ({ params, context }) => {
    await Promise.all([
      context.queryClient.prefetchQuery({
        queryKey: ['product', params.productId],
        queryFn: () => fetchProduct(params.productId),
      }),
      context.queryClient.prefetchQuery({
        queryKey: ['related-products', params.productId],
        queryFn: () => fetchRelatedProducts(params.productId),
      }),
      context.queryClient.prefetchQuery({
        queryKey: ['product-reviews', params.productId],
        queryFn: () => fetchProductReviews(params.productId),
      }),
    ]);
  },
  pendingComponent: ProductPageSkeleton,
  errorComponent: ProductErrorPage,
});

// ProductPage component
function ProductPage() {
  const { productId } = useParams();

  const { data: product } = useSuspenseQuery(...);
  const { data: relatedProducts } = useSuspenseQuery(...);
  const { data: reviews } = useSuspenseQuery(...);

  // Defer non-critical data
  const shippingQuery = useQuery({
    queryKey: ['shipping-info', productId],
    queryFn: () => fetchShippingInfo(productId),
  });

  return (
    <div className="product-page">
      <ProductDetails product={product} />
      <RelatedProducts products={relatedProducts} />
      <ProductReviews reviews={reviews} />
      {shippingQuery.isSuccess && (
        <ShippingInfo info={shippingQuery.data} />
      )}
    </div>
  );
}

Conclusion

The route-based data fetching pattern offers significant advantages:

  1. Improved performance: Fewer renders and parallel loading
  2. Cleaner components: Focused on presentation
  3. Better user experience: Faster perceived load times
  4. Easier maintenance: Centralized data logic

While this pattern requires some initial setup (especially with routing libraries), the long-term benefits for application architecture and performance make it worthwhile for most React applications.

Remember that not all data needs to be fetched at the route level - use this pattern for critical data needed immediately, while still using traditional approaches for secondary data that can load after initial render.