I opened my Next.js app one morning and counted 23 useEffect blocks whose only job was to fetch data on mount. Dashboards, profile pages, comment threads, the chatbot history view. Every one of them followed the same sad pattern: render nothing, flash a spinner, wait for the client to wake up, fire a request, set state, re-render. It worked. It also felt wrong in a way I had been ignoring for months.
So I spent a weekend ripping all of it out. Server Components handle the reads now. Server Actions handle the writes. I kept useEffect around for exactly the things it was designed for, and nothing else. This is the refactor diary, including the parts where I broke things and had to back out.
Why was useEffect data fetching the wrong default?
Because it forces the browser to do work the server could have already finished. The classic useEffect(() => { fetch(...) }, []) pattern ships empty HTML, boots React on the client, runs the effect, opens a network request, parses JSON, then finally paints the thing the user came for. On a fast laptop nobody notices. On a mid-range Android phone on a train in Bhubaneswar, it is a full second of staring at a spinner.
There are three specific problems I kept hitting.
The first is the waterfall. A page with three useEffect fetches inside three different components ends up making three sequential round trips after hydration finishes. Each one waits for the component above it to mount. I had a profile page where the avatar, the stats card, and the recent activity list were fetched this way. Total time to interactive on a throttled connection was over three seconds, and the API itself responded in under 150ms each.
The second is the flash of nothing. You need a loading state, an error state, a data state, and a guard for the unmounted case. I wrote the same if (loading) return <Skeleton /> branch so many times I turned it into a hook, then turned that hook into a bigger hook, and eventually realised I had built a worse version of React Query without meaning to.
The third is SEO and AI crawlers. Google renders JavaScript, but it does not love it. ChatGPT's crawler and Perplexity's crawler are even less patient. If the meat of your page only appears after a client fetch, you are trusting a lot of bots to stick around. On this portfolio I care about that, because half my traffic comes from people asking an AI a question.
How do Server Components actually replace the fetch-on-mount pattern?
Server Components let you await your data directly in the component body, on the server, before any HTML reaches the browser. There is no hook, no state, no effect. The component is an async function, and whatever it returns is already resolved by the time the user sees it.
Here is the before and after from my activity feed. This is real code from the refactor, trimmed for the post.
Old client component with useEffect:
'use client'
import { useEffect, useState } from 'react'
export function ActivityFeed({ userId }: { userId: string }) {
const [items, setItems] = useState<Activity[] | null>(null)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let cancelled = false
fetch(`/api/activity?userId=${userId}`)
.then((r) => r.json())
.then((data) => { if (!cancelled) setItems(data) })
.catch((e) => { if (!cancelled) setError(e) })
return () => { cancelled = true }
}, [userId])
if (error) return <ErrorCard error={error} />
if (!items) return <FeedSkeleton />
return <FeedList items={items} />
}New Server Component:
import { getActivity } from '@/lib/activity'
import { FeedList } from './feed-list'
export async function ActivityFeed({ userId }: { userId: string }) {
const items = await getActivity(userId)
return <FeedList items={items} />
}That is the whole component. No hook, no state, no cancellation flag, no skeleton branch inside the component itself. The skeleton now lives in a loading.tsx file next to the route, and React Suspense streams the real content in when it is ready. The error case lives in error.tsx. Both of those are built into the App Router, and I had been ignoring them because my brain was still wired for the old pattern.
The nicest part is that getActivity is now a plain async function that talks to the database directly. No API route, no serializer, no extra network hop. The database call and the page render happen in the same process, on the same machine, inside the same request.
Where do Server Actions fit once reads move to the server?
Server Actions handle the write half of the story. When the user clicks Like, posts a comment, or updates a setting, I used to fetch('/api/comments', { method: 'POST' }) from inside a useEffect or a submit handler, then manually refetch the list. Now the mutation is a function with 'use server' on top, and the page revalidates itself.
Here is the comment form, before and after.
Old pattern:
'use client'
async function submit(text: string) {
await fetch('/api/comments', {
method: 'POST',
body: JSON.stringify({ text }),
})
// now manually refetch the list, or reload the page, or
// hope a SWR mutate call covers it
}New pattern:
// app/posts/[slug]/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
import { db } from '@/lib/db'
export async function addComment(postId: string, formData: FormData) {
const text = String(formData.get('text') ?? '').trim()
if (!text) return { ok: false, error: 'Empty comment' }
await db.comment.create({ data: { postId, text } })
revalidateTag(`comments:${postId}`)
return { ok: true }
}// comment-form.tsx
'use client'
import { useActionState } from 'react'
import { addComment } from './actions'
export function CommentForm({ postId }: { postId: string }) {
const [state, formAction, pending] = useActionState(
addComment.bind(null, postId),
{ ok: false }
)
return (
<form action={formAction}>
<textarea name="text" required />
<button disabled={pending}>{pending ? 'Posting...' : 'Post'}</button>
{state.error && <p className="text-red-500">{state.error}</p>}
</form>
)
}No useEffect. No manual refetch. revalidateTag tells Next.js that anything tagged comments:${postId} is stale, and the next render of the Server Component that reads those comments will pick up the new row. The pending and error state come from useActionState, which is a proper React hook that knows about the action's lifecycle.
This is where the mental model finally clicked for me. Reads live on the server and are cached per route or per tag. Writes go through Server Actions and invalidate tags. The client is only responsible for the things the client is actually good at: typing, clicking, and showing pending UI.
What broke during the refactor?
Three things broke, and I want to be honest about them because every "rewrite everything" post pretends the rewrite was smooth.
Search and filter state stopped working the old way. I had a search input that used useEffect to refetch results whenever the query changed. Moving the list to a Server Component meant the query had to live somewhere the server could see. I moved it into the URL as a search param, and now the Server Component reads await searchParams and refetches on every URL change. This is better in the long run (shareable URLs, back button works, no client state to sync) but it took a morning to rewire every filter and debounce the input without re-introducing a useEffect.
Optimistic updates got harder before they got easier. The old pattern let me shove the new comment into local state instantly and reconcile later. With Server Actions you get useOptimistic for this, and it works well, but the API is new and I had to read the docs twice. The first version of my comment list flickered because I was updating the optimistic state inside the wrong component. Once I moved it to the list component itself, it was smoother than the old code.
I accidentally broke streaming on one route. I wrapped a Server Component in a client boundary by mistake, which forced the whole subtree to render on the client and killed the streaming behavior. The fix was to push 'use client' down to the actual interactive leaf (a button), not the card that contained it. The rule I repeat to myself now is: the client boundary goes on the smallest thing that needs it.
None of these were dealbreakers. All of them were things my old useEffect-everywhere code had papered over by being uniformly mediocre.
When do I still reach for useEffect?
I still use useEffect whenever the work is genuinely browser-only. Reading from localStorage for a theme preference. Subscribing to a matchMedia query. Wiring up an IntersectionObserver for a scroll reveal. Mounting a third-party chart library that touches the DOM directly. Syncing a piece of state to the URL hash. None of that belongs on the server, and pretending otherwise leads to weird hydration errors.
The rule I landed on is short enough to put on a sticky note. If the effect is fetching data, it should probably be a Server Component. If the effect is touching the browser, it is still a useEffect. Everything else is a judgment call, and the judgment usually lands on the server side now.
Was the refactor worth it?
Yes, and I was not expecting the numbers to be this clear. On my profile page, time to first contentful paint dropped from around 1.4s to 380ms on a simulated 4G connection. The JavaScript bundle for that route shrank by 31KB because I deleted the client-side fetch helpers and their tiny useEffect wrappers. The code itself is shorter: the activity feed alone went from 47 lines to 9.
The part I did not expect was how much calmer the code felt to read afterwards. A Server Component is just an async function that returns JSX. There is no lifecycle to reason about, no cancellation token, no stale closure traps. When something breaks, the stack trace points at the line that actually broke, not at a useEffect three components up that fired in the wrong order.
If you are still writing useEffect(() => { fetch(...) }, []) in a new Next.js 16 project, I would push back on that default. The App Router has been stable long enough, the Server Actions API is no longer experimental, and the tooling around Suspense and streaming finally feels finished. The only real cost of moving is a few afternoons spent unlearning habits from the Pages Router era.
For more on the patterns in this post, see the Next.js Server Components docs, the Server Actions and Mutations guide, and Dan Abramov's You Might Not Need an Effect from the official React docs.
Keep Reading
- Hello proxy.ts: the Next.js 16 middleware rename — Another Next.js 16 habit you probably need to unlearn.
- Building a Modern Docs Generator with Next.js 16 — How I use Server Components and streaming for a real content-heavy app.
- Full-Stack Social Identity with Spring and Next.js — Where Server Actions meet a real backend.
- The Day a React Patch Broke the Internet — Why I trust the React team but still read every changelog twice.
