DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

Building SaaS with React and TanStack Query: A Complete Architecture Guide

Building SaaS with React and TanStack Query: A Complete Architecture Guide

I've built CitizenApp—a production SaaS with nine AI features across multiple tenants—and I can tell you with certainty: how you manage server state in React makes or breaks your SaaS architecture. TanStack Query (formerly React Query) isn't optional if you're serious about scaling. It's foundational.

Most React developers I mentor treat data fetching as a side effect problem. They sprinkle useEffect with fetch calls, manage loading states manually, handle cache invalidation ad-hoc, and wonder why their app feels janky when users switch between pages. That's not React's fault—it's an architecture problem. TanStack Query solves this by centralizing server state management as a first-class citizen.

Here's why this matters for SaaS specifically: your users expect instant feedback. They switch between workspaces, edit records, invite teammates. Every action triggers API calls. Without proper cache invalidation and optimistic updates, your app feels slow even if your backend is fast. I learned this the hard way in CitizenApp's early days.

The Core Problem: React Doesn't Solve Server State

React manages component state beautifully. But server state is different:

  • It's not owned by the component
  • It can become stale asynchronously
  • It requires synchronization across multiple components
  • Cache invalidation is genuinely hard

Using Redux or Zustand for server state is like using a sledgehammer for a nail. I did this once. I spent days writing reducers for API calls, managing loading flags, handling pagination offsets, and syncing window focus events. It worked, but it was brittle. When requirements changed, the state management code became my bottleneck, not the API.

TanStack Query abstracts this complexity into a declarative API that understands server state semantics natively.

Architecture Pattern: Query + Mutation + Background Sync

Here's how I structure SaaS applications now:

// hooks/useWorkspace.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';

export function useWorkspace(workspaceId: string) {
  return useQuery({
    queryKey: ['workspace', workspaceId],
    queryFn: async () => {
      const response = await api.get(`/workspaces/${workspaceId}`);
      return response.data;
    },
    staleTime: 5 * 60 * 1000, // 5 minutes
    gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime)
  });
}

export function useUpdateWorkspace() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (data: { workspaceId: string; name: string }) => {
      const response = await api.patch(
        `/workspaces/${data.workspaceId}`,
        { name: data.name }
      );
      return response.data;
    },
    onMutate: async (newData) => {
      // Optimistic update
      const previousData = queryClient.getQueryData(['workspace', newData.workspaceId]);

      queryClient.setQueryData(['workspace', newData.workspaceId], (old: any) => ({
        ...old,
        name: newData.name,
      }));

      return { previousData };
    },
    onError: (error, newData, context) => {
      // Rollback on error
      if (context?.previousData) {
        queryClient.setQueryData(
          ['workspace', newData.workspaceId],
          context.previousData
        );
      }
    },
    onSuccess: (data, variables) => {
      // Ensure consistency after successful mutation
      queryClient.setQueryData(
        ['workspace', variables.workspaceId],
        data
      );
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

This pattern is powerful because:

  1. Optimistic updates feel instant to users
  2. Automatic rollback if the API fails
  3. Single source of truth for workspace data across your entire app
  4. Background refetching handles stale data without user intervention

Multi-Tenant Cache Invalidation: The Real Challenge

In CitizenApp, I handle 9 different features across multiple workspaces. Cache invalidation becomes complex quickly. Here's my approach:

// lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,
      gcTime: 30 * 60 * 1000,
      retry: 1,
      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
    },
  },
});

// Invalidation helpers
export const invalidateWorkspaceQueries = (workspaceId: string) => {
  queryClient.invalidateQueries({
    queryKey: ['workspace', workspaceId],
  });
};

export const invalidateWorkspaceMembers = (workspaceId: string) => {
  queryClient.invalidateQueries({
    queryKey: ['workspace', workspaceId, 'members'],
  });
};

export const invalidateWorkspaceAll = (workspaceId: string) => {
  queryClient.invalidateQueries({
    queryKey: ['workspace', workspaceId],
  });
};
Enter fullscreen mode Exit fullscreen mode

Then in mutation handlers:

onSuccess: (data, variables) => {
  queryClient.setQueryData(
    ['workspace', variables.workspaceId],
    data
  );
  // Invalidate related queries
  invalidateWorkspaceMembers(variables.workspaceId);
  invalidateWorkspaceAll(variables.workspaceId);
}
Enter fullscreen mode Exit fullscreen mode

The key insight: design your query keys hierarchically. Prefix them with the resource type, then the ID, then specifics. This lets you invalidate at any level with surgical precision.

Server-Side Considerations

Your backend matters too. Here's the FastAPI pattern I use:

# backend/routes/workspaces.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.models import Workspace
from app.schemas import WorkspaceUpdate
from app.auth import get_current_user

router = APIRouter(prefix="/workspaces")

@router.patch("/{workspace_id}", response_model=WorkspaceSchema)
async def update_workspace(
    workspace_id: str,
    data: WorkspaceUpdate,
    session: Session = Depends(get_db),
    user = Depends(get_current_user),
):
    workspace = session.query(Workspace).filter(
        Workspace.id == workspace_id
    ).first()

    if not workspace:
        raise HTTPException(status_code=404)

    # RBAC check
    if not user.can_edit_workspace(workspace):
        raise HTTPException(status_code=403)

    workspace.name = data.name
    workspace.updated_at = datetime.utcnow()
    session.commit()

    return workspace
Enter fullscreen mode Exit fullscreen mode

The API should always return the updated resource. This ensures your frontend stays in sync without additional queries.

Gotcha: Window Focus Refetching Can Cause Thrashing

I set refetchOnWindowFocus: true by default. This is great—your app stays current when users switch tabs. But in production, I discovered it causes problems with rapid tab switching on slow connections. Users see loading spinners appear and disappear constantly, even if the data hasn't changed.

My fix:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: 'stale', // Only refetch if data is stale
      staleTime: 5 * 60 * 1000, // 5 minutes = 300 seconds
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

This way, if the user tabs back within 5 minutes, there's no refetch. If they're gone longer, data refetches quietly in the background. No jarring UI updates.

What I Missed Early On

I initially didn't use onMutate for optimistic updates. Every action—renaming a workspace, adding a member—required a full page refetch. Users hated it. The app felt sluggish even on fast connections. Once I added optimistic updates with proper rollback, the perceived performance improved dramatically.

Also, I didn't think about pagination initially. My first instinct was to fetch all records. TanStack Query handles pagination elegantly:

export function useWorkspaceMembers(workspaceId: string, page: number) {
  return useQuery({
    queryKey: ['workspace', workspaceId, 'members', page],
    queryFn: async () => {
      const response = await api.get(
        `/workspaces/${workspaceId}/members?page=${page}`
      );
      return response.data;
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Each page is cached independently. Users can navigate forward and backward instantly.

Final Thoughts

TanStack Query isn't magic, but it's the closest thing to it for SaaS applications. It eliminates entire categories of bugs—race conditions, stale data, cache inconsistency. Your component code becomes simpler. Your users get a faster app.

In CitizenApp, TanStack Query handles the orchestration of 9 different AI features across multiple tenants. Without it, that complexity would be unmanageable. Start with queries and mutations. Add optimistic updates once users care about latency. Design query keys hierarchically from day one.

Your SaaS will scale better because of it.

Top comments (0)