DEV Community

Cover image for Stop Wasting Renders: How to Handle Complex REST APIs in Your Frontend
Amrendra kumar
Amrendra kumar

Posted on

Stop Wasting Renders: How to Handle Complex REST APIs in Your Frontend

React REST API render optimization comes down to three problems: mixing server state with client state in useState, firing network requests on every component mount because staleTime defaults to 0, and creating request waterfalls through nested component data fetching.

This article covers the three architectural patterns that cause render waste when integrating REST APIs in React, and the exact production fixes for each. You will come away knowing how to separate server state from client state, configure TanStack Query's cache correctly, and eliminate request waterfalls without restructuring your entire component tree.

If you are also thinking about how component architecture affects render behaviour at scale, Building Reusable React Component Systems at Scale covers the structural decisions that compound with API integration patterns.

The Real Source of React Render Waste

Most render waste does not come from missing React.memo. It comes from storing server state in local useState.

Server state and client state have fundamentally different update requirements:

  • Server state: async, shared across components, stale after time, needs background refresh
  • Client state: synchronous, local to a component or feature, user-driven

When you put an API response into useState, every parent re-render, every context update, every sibling state change can cause your fetch cycle to reset or your component to re-render with data it already had. There is no deduplication, no shared cache, no background refresh. Separating these two concerns is the foundational step before any library decision.

For teams building on Next.js, the same separation applies at the framework level — Building Scalable Next.js Applications covers how to structure data fetching across App Router without coupling server state to component lifecycle.

Why useEffect + useState for API Calls Doesn't Scale

// This pattern breaks at scale
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => res.json())
      .then(setUser);

    // AbortController required to handle race conditions
    // when userId changes faster than the previous request resolves
    return () => controller.abort();
  }, [userId]);
}
Enter fullscreen mode Exit fullscreen mode

Two components mounting simultaneously with the same userId fire two separate network requests. No shared cache. No deduplication.

Manual AbortController just to handle race conditions. This is not a pattern to optimise with memoization it is a pattern to replace above a certain complexity threshold.

Request Waterfalls: How to Diagnose and Fix Them

A request waterfall is when network requests execute sequentially that could run in parallel. Two patterns cause this in React.

Pattern 1 : Dependent queries: fetch user, then fetch posts using the user ID. The posts request cannot start until the user request resolves.

Pattern 2 : Nested component waterfalls: parent fetches data, conditionally renders a child, child fetches its own data. The child's request cannot start until the parent finishes rendering.

Both are diagnosable in Chrome DevTools Network tab. Look for sequential same-domain requests with no timing overlap.

Fixing Dependent Queries with useQueries

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

function Dashboard({ userId }: { userId: string }) {
  const results = useQueries({
    queries: [
      {
        queryKey: ['user', userId],
        queryFn: () => fetchUser(userId),
      },
      {
        queryKey: ['posts', userId],
        queryFn: () => fetchPosts(userId),
        // enabled prevents firing until userId exists,
        // but does NOT create a waterfall both queries
        // are registered and run in parallel on mount
        enabled: !!userId,
      },
    ],
  });

  const [userQuery, postsQuery] = results;
}
Enter fullscreen mode Exit fullscreen mode

As per TanStack Query v5 docs (tanstack.com/query/latest/docs), useQueries runs all queries in parallel by default. The enabled flag controls whether a query fires not whether it waits for another query to complete.

Fixing Nested Component Waterfalls with Prefetching

// Route loader  executes before the component tree renders
export async function loader({ params }) {
  const queryClient = getQueryClient();

  // Prefetch child data so it's already in cache
  // when the child component mounts and calls useQuery
  await queryClient.prefetchQuery({
    queryKey: ['comments', params.postId],
    queryFn: () => fetchComments(params.postId),
  });

  return null;
}
Enter fullscreen mode Exit fullscreen mode

The child component calls useQuery for comments and gets a cache hit immediately. The network request is gone from the component render cycle entirely.

staleTime and gcTime: The Two Config Values That Matter Most

The TanStack Query default is staleTime: 0. This means every component mount is treated as a cache miss and fires a network request. In a dashboard with 10 components reading the same endpoint, that is 10 requests on a single page load.

Configure staleTime per data type based on how often that data actually changes:

Data Type Recommended staleTime gcTime Reasoning
User profile 5 minutes 10 minutes Changes rarely mid-session
Live feed / notifications 10-30 seconds 1 minute Needs to stay current
Config / reference data 1 hour 24 hours Almost never changes
Search results 30 seconds 2 minutes User expects fresh results

Source: TanStack Query v5 docs (tanstack.com/query/latest/docs). Configuration values based on production patterns. Last updated: June 2026.

For multi-tenant SaaS products where different tenants have different data freshness requirements, this configuration approach changes significantly — Designing Multi-Tenant SaaS Platforms for Scale covers the database and routing strategies that inform how per-tenant cache configuration should be structured.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,  // 5 minutes: safe default for stable data
      gcTime: 10 * 60 * 1000,    // 10 minutes: keep in memory after unmount
    },
  },
});

// Override per query where freshness requirements differ
useQuery({
  queryKey: ['notifications'],
  queryFn: fetchNotifications,
  staleTime: 15 * 1000, // 15 seconds: this data must stay current
});
Enter fullscreen mode Exit fullscreen mode

gcTime controls when inactive cached data gets garbage collected not how long it is considered fresh. Setting gcTime lower than staleTime means data is garbage collected before it goes stale, which defeats the cache entirely.

SWR vs TanStack Query: Which One for Your Use Case

Feature SWR v2 TanStack Query v5
Bundle size (gzipped) ~4.2KB ~13KB
Request deduplication YES YES
staleTime control Limited Full, per-query
Mutation handling useSWRMutation (basic) useMutation (full)
DevTools NO (official) YES
useSuspenseQueries NO YES
TypeScript inference Good Excellent (v5)
Optimistic updates Manual Built-in with rollback

Source: swr.vercel.app, tanstack.com/query/latest/docs. Bundle sizes from bundlephobia.com. Last updated: June 2026.

Decision rule: Use SWR if you need minimal bundle size and simple fetch-cache-revalidate patterns with Next.js. Use TanStack Query if you have mutations, dependent queries, DevTools requirements, or need useSuspenseQueries for waterfall elimination.

Optimistic Updates Without Breaking Data Integrity

The trap with optimistic updates is rollback handling. If the mutation fails, you must revert the UI without a flash or a stale cache entry surviving into the next render cycle.

const mutation = useMutation({
  mutationFn: (updatedPost) => updatePost(updatedPost),

  onMutate: async (updatedPost) => {
    // Cancel outgoing refetches to avoid overwriting
    // the optimistic update with stale server data
    await queryClient.cancelQueries({ queryKey: ['posts', updatedPost.id] });

    // Snapshot the previous value  this is the rollback target
    const previousPost = queryClient.getQueryData(['posts', updatedPost.id]);

    // Apply the optimistic change to the cache immediately
    queryClient.setQueryData(['posts', updatedPost.id], updatedPost);

    return { previousPost };
  },

  onError: (err, updatedPost, context) => {
    // Restore snapshot on failure
    queryClient.setQueryData(['posts', updatedPost.id], context.previousPost);
  },

  onSettled: (updatedPost) => {
    // Always refetch after success or error
    // to sync cache with actual server truth
    queryClient.invalidateQueries({ queryKey: ['posts', updatedPost.id] });
  },
});
Enter fullscreen mode Exit fullscreen mode

onSettled runs after both success and error, ensuring the cache eventually reflects server truth regardless of what happened during the optimistic phase. Skipping onSettled leaves the cache in an optimistic state permanently if the server silently rejects the mutation

Production Checklist: React REST API Integration

Before shipping any React feature that calls a REST API, verify:

  • Server state lives in TanStack Query or SWR, not in useState
  • staleTime is configured per query based on actual data freshness requirements — not left at the default of 0
  • Network tab shows no sequential same-domain requests that could run in parallel
  • Mutations use onError rollback — not just onSuccess handling
  • QueryClient is instantiated per request in SSR contexts, never at module level (module-level QueryClient leaks data across users in server environments)
  • Query cancellation is wired for queries that depend on rapidly-changing props like search input

Conclusion

React render waste from REST APIs is an architectural problem, not a component-level one. The fix starts with separating server state from client state, then configuring your cache to match actual data freshness requirements, then eliminating waterfalls before they reach production.

Every pattern in this article is production-tested and applies directly to dashboards, SaaS products, and admin panels hitting REST endpoints at scale.

If this helped, follow on GitHub for production React patterns and annotated code samples: github.com/AmrendraCodes

About the Author

Amrendra Kumar is a software engineer and technical writer at Code with Amrendra, where he covers React, Next.js, AI Agents, SaaS architecture, and cloud infrastructure.

LinkedIn | GitHub

Top comments (0)