DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

Declarative Data Fetching in React 19: Replacing useEffect + useState with Server Components and Suspense Boundaries

Declarative Data Fetching in React 19: Replacing useEffect + useState with Server Components and Suspense Boundaries

I spent three years watching teams write the same useEffectuseStateloadingerrordata loop 200 times across a codebase. Infinite dependency array bugs. Race conditions where a slow request comes back after a newer one. Waterfalls where you fetch User, then fetch Settings based on userId, then fetch Permissions. All of it preventable.

React 19 with Server Components and Suspense doesn't just make this pattern optional—it makes the old way feel genuinely wrong. But the syntax is unfamiliar, and most teams default to what they know. This post is a direct challenge: unlearn useEffect for data fetching. The mental shift is small; the impact is massive.

Why useEffect for Data Fetching Is a Temporal Antipattern

The core problem: useEffect treats data fetching as an effect of rendering, something you do after the component mounts. This is backwards. Data dependencies should be declared before render. You know what data you need to display something—declare it upfront.

This creates three categories of bugs I've personally debugged in production:

Race conditions. User navigates from /users/1 to /users/2. Your useEffect fires twice. If the /users/2 request completes before /users/1, you render the wrong data. The fix is always a cleanup flag or abort controller. Exhausting.

Waterfall requests. You fetch user data, then in another useEffect, you fetch settings based on userId. Network waterfall. Could've been parallel.

Bundle size. Every component that fetches data adds client JavaScript for the state machine—loading, error, retry logic. On CitizenApp, we had AI features with 8+ sequential fetches. The client bundle was carrying code that could've lived on the server.

Server Components + Suspense inverts this: data fetching is declaration, not effect.

The Server Component Model: Declarative Data at Definition Time

In React 19, Server Components are async. You declare data needs by awaiting:

// app/dashboard/page.tsx (Server Component)
import { Suspense } from 'react';
import { AIFeatureList } from '@/components/AIFeatureList';
import { FeatureSkeleton } from '@/components/FeatureSkeleton';

async function loadAIFeatures(tenantId: string) {
  const response = await fetch(`/api/tenants/${tenantId}/features`, {
    headers: { Authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}` },
  });
  if (!response.ok) throw new Error('Failed to load features');
  return response.json();
}

export default async function DashboardPage({ params }: { params: { tenantId: string } }) {
  const features = await loadAIFeatures(params.tenantId);

  return (
    <div>
      <h1>Your AI Features</h1>
      <Suspense fallback={<FeatureSkeleton />}>
        <AIFeatureList features={features} />
      </Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice what's absent: no useState, no useEffect, no loading state. The component doesn't render until features resolves. If it errors, the error boundary catches it (which I've written about separately). This is declaration—you need features, so you await them before rendering.

On the backend (FastAPI), nothing changes in your API signature:

# app/api/features.py
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
from app.models import AIFeature
from app.db import get_db

app = FastAPI()

@app.get("/api/tenants/{tenant_id}/features")
async def list_features(
    tenant_id: str,
    db: Session = Depends(get_db),
):
    """List all AI features for a tenant."""
    features = db.query(AIFeature).filter(
        AIFeature.tenant_id == tenant_id,
        AIFeature.deleted_at.is_(None),  # soft delete
    ).all()

    return [
        {
            "id": f.id,
            "name": f.name,
            "config": f.config,  # JSONB
            "created_at": f.created_at.isoformat(),
        }
        for f in features
    ]
Enter fullscreen mode Exit fullscreen mode

Same API. The difference is where the fetch happens and when it's initiated.

Parallel Requests Without Waterfall

This is where declarative fetching wins hard. In a useEffect world, you'd do:

// OLD: useEffect waterfall anti-pattern
const [user, setUser] = useState(null);
const [settings, setSettings] = useState(null);

useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]);

useEffect(() => {
  if (user) {
    fetchSettings(user.id).then(setSettings); // Waterfall!
  }
}, [user]);
Enter fullscreen mode Exit fullscreen mode

With Server Components, you declare both data needs up front, and React streams them in parallel:

// NEW: Parallel declarations
async function loadDashboard(tenantId: string) {
  const [user, settings, features] = await Promise.all([
    fetch(`/api/tenants/${tenantId}/user`),
    fetch(`/api/tenants/${tenantId}/settings`),
    fetch(`/api/tenants/${tenantId}/features`),
  ]).then(async (responses) => {
    return Promise.all(responses.map((r) => r.json()));
  });

  return { user, settings, features };
}

export default async function Dashboard({ params }: { params: { tenantId: string } }) {
  const { user, settings, features } = await loadDashboard(params.tenantId);

  return (
    <div>
      <UserCard user={user} />
      <SettingsPanel settings={settings} />
      <Suspense fallback={<Skeleton />}>
        <AIFeatureList features={features} />
      </Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Three network requests fire simultaneously. No waterfall. No conditional fetching logic scattered across useEffect hooks.

Client Components Still Need use() for Interactivity

Not everything is a Server Component. When you need interactivity—a form submission, real-time updates, state—you drop to a Client Component. React 19 gives you use() to unwrap promises from Server Components:

// 'use client'
import { use } from 'react';

interface AIFeatureListProps {
  featuresPromise: Promise<AIFeature[]>;
}

export function AIFeatureList({ featuresPromise }: AIFeatureListProps) {
  const features = use(featuresPromise);
  const [activeFeature, setActiveFeature] = useState(features[0]);

  return (
    <div>
      {features.map((f) => (
        <button key={f.id} onClick={() => setActiveFeature(f)}>
          {f.name}
        </button>
      ))}
      <ConfigPanel feature={activeFeature} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

You pass the promise to the client component, unwrap it with use(), and now you have state for local interactivity. The heavy lifting—data fetching, authorization checks—happened on the server. The client is lean.

Gotcha: Suspense Boundaries Must Wrap Async Components

Here's where I burned myself initially. You can't do this:

export default async function Page() {
  const data = await fetch('/api/data');

  return <Suspense fallback={<Skeleton />}>{/* too late */}</Suspense>;
}
Enter fullscreen mode Exit fullscreen mode

Suspense only works if the async operation happens inside a component that's wrapped by the boundary. The async parent triggers Suspense before rendering anything. You need to isolate the async fetch into its own component:

async function DataLoader() {
  const data = await fetch('/api/data');
  return <DataDisplay data={data} />;
}

export default function Page() {
  return (
    <Suspense fallback={<Skeleton />}>
      <DataLoader />
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the Suspense boundary catches the DataLoader promise and shows the fallback while it resolves.

The Real Shift: Thinking in Declarations

The syntax change is small. The mental model shift is everything. You stop thinking "when should I fetch this?" and start thinking "what data does this component need?" You declare dependencies at definition time, not scattered across useEffect hooks.

On CitizenApp, this reduced client bundle size by 28KB and eliminated three categories of race-condition bugs. Worth unlearning eight years of useEffect muscle memory? Absolutely.

Top comments (0)