Declarative Data Fetching in React 19: Replacing useEffect + useState with Server Components and Suspense Boundaries
I spent three years watching teams write the same useEffect → useState → loading → error → data 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>
);
}
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
]
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]);
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>
);
}
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>
);
}
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>;
}
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>
);
}
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)