Install
$ agentstack add skill-lukedj78-dev-flow-data-fetching ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
data-fetching — Server Components first, never useEffect for reads
This skill governs where data reads land in a Next.js 16 App Router app. The framework lets you call a "use server" function from a Client Component — that's a capability, not a license. Reading data via a Server Action in useEffect costs you SSR, streaming, request deduping, caching, and parallelism. The bug is silent: no error, no warning, just worse UX and wasted POSTs.
When this skill applies
- The user is about to call a Server Action from a Client Component to load data.
- The user is about to add
useEffect(at all — but especially to fetch). - The user pastes
"use client" + useState + useEffect + fetch/getXand asks for review. - The user is about to convert a page to
"use client"so it can host filter/tab state. - The user adds a
"use server"function whose only job isSELECT/ read. - The user asks to audit a Next.js codebase against the data-fetching rules.
Contract
Follows the dev-flow contract — see references/contracts.md. Key facts:
- Reads
meta.json#stack.frameworkandstack.nextjs_version. Forframework = "monorepo", readsstack.monorepo.web.frameworkandstack.monorepo.web.nextjs_version. - Refuses to apply if:
stack.framework ∉ {"next", "monorepo"}— Server Components / Server Actions don't exist on RN, Remix, SvelteKit, Astro, plain React, etc.stack.nextjs_version != "16"—searchParamsis async in 16 (was sync in 15),revalidatePathimport path moved,refresh()fromnext/cacheis new. Do not silently translate the rules.- The project uses Pages Router (
pages/directory). Different mental model (getServerSideProps/getStaticProps/ API routes / SWR) — refuse rather than translate. - Appends a
historyentry per refactor. - Does not bump
phase.
Companion skills
state-discipline(sibling in dev-flow) — owns the broader React-side rule (never bareuseEffect; derive state, use a query lib, use event handlers,keyto reset,useMountEffectfor one-time external sync). Install and follow it alongside this skill.forms— for any UI that persists field values to the backend. Forms are mutation-heavy and have their own toolkit; reads inside a form (e.g. preloading the entity to edit) follow this skill's rules.
If a green example below looks like it would have been a useEffect in older code, that's the point — it isn't one anymore. The red ❌ blocks show useEffect only because that's what the anti-pattern looks like in the wild; never copy from a red block.
The Rule
Read data in Server Components. Mutate data with Server Actions. Never use useEffect (or useState + useEffect) in a Client Component to call a Server Action just to load data.
Violating the letter is violating the spirit. The signal that you've drifted is not a runtime error (there is none), it's the patterns below: a getX action, a useEffect that fetches, a page newly converted to "use client". The absence of a stack trace is not the absence of a problem.
Why
> "Server Functions are designed for server-side mutations, and the client currently dispatches and awaits them one at a time. […] If you need parallel data fetching, use data fetching in Server Components." > — Next.js docs, mutating-data.mdx
> "Server Actions are queued, and using them for data fetching introduces sequential execution." > — Next.js docs, backend-for-frontend.mdx
Concretely, a useEffect-driven Server Action read costs:
- No SSR — the page paints empty, then fetches after hydration. Worst LCP.
- Sequential queue — every Server Action call waits on the previous one.
- No request deduping / caching — Server Actions always POST.
- No streaming — no progressive render with ``.
- Double-fetch on mount in Strict Mode dev.
- Larger client bundle — fetch logic, loading states, error states ship to the browser.
Decision: how to load data
The first question is not "where does the data need to land?" — it's "why is this a Client Component at all?" Most reads belong on the server. If the answer is anything weaker than "polling, focus refetch, or a third party mutates the data without user intent," the fix is to lift the read to a Server Component, not to swap the transport.
digraph data_fetching {
"Why is this a Client Component?" [shape=diamond];
"Server Component, await directly" [shape=box];
"URL searchParams; page stays Server Component" [shape=box];
"Promise from Server Component, use() in Client leaf" [shape=box];
"Route Handler GET + TanStack Query (last resort)" [shape=box];
"Why is this a Client Component?" -> "Server Component, await directly" [label="It isn't / shouldn't be"];
"Why is this a Client Component?" -> "URL searchParams; page stays Server Component" [label="Filter / tab / range state"];
"Why is this a Client Component?" -> "Promise from Server Component, use() in Client leaf" [label="Genuine interactivity at the data boundary, initial data only"];
"Why is this a Client Component?" -> "Route Handler GET + TanStack Query (last resort)" [label="Polling, focus refetch, or third-party mutates the data"];
}
The branches are not peers. Top to bottom: Server Component (default, ~90% of cases), URL state (most "I need filters" cases), use() + `` (rare), Route Handler + TanStack Query (last resort, narrow scope). Reaching for the bottom branch when an upper branch fits is the most common failure mode of this skill.
Server Actions are for mutations only.
Migrating away from useEffect + Server Action — the ladder
If you're staring at useState + useEffect + a "use server" read in a Client Component, walk this ladder top-down and stop at the first rung that fits. It's almost always rung 1.
- Lift the read to a Server Component. Convert the page to
async function Page({ searchParams }),awaitthe read at the top, pass data down. If the page has interactive state, ask rung 2 before deciding it has to stay client. - Move state to URL
searchParams. Tabs, filters, ranges, pagination, sort, search query — all belong in the URL. The Client leaf callsrouter.replace; the Server Component re-renders with new data. Free streaming, free cache, shareable URL, back-button works. - Pass
Promisefrom Server Component, consume withuse()+ ``. Only when a Client Component genuinely needs server data as props at mount (charting libs, third-party widgets expecting a synchronous data shape). GETRoute Handler + TanStack Query (recommended default for this rung — retries, request dedup, devtools, mutation helpers; SWR is an acceptable lighter-weight alternative for a single simple polling widget, but don't reach for a second data library once TanStack Query is already in the project). Reserved for: interval polling, focus revalidation, third-party mutates the data outside your app. Not for "I already have a Client Component and want to keep it."
The lateral migration is the failure mode
useEffect + action → useQuery/useSWR + Route Handler in the same Client Component is the wrong refactor. It feels like progress — no more action-as-read — but:
- Page is still
"use client". No SSR, no streaming, same bad LCP. - You traded a sequential POST queue for a sequential
fetch. Same waterfall. - "Route Handlers cache!" — not for per-user, per-org reads. Your
/api/casesisCache-Control: private; the CDN won't touch it. - You added a network hop, a JSON serialization layer, a client library, an extra route file — for zero cache wins over the Server Component you should have written.
If you reached rung 4 without first asking "can this page simply be a Server Component?", back up.
The four correct patterns
1. Async Server Component — the default
// app/(app)/cases/page.tsx
import { listCases } from "@/lib/services/case.service";
export default async function CasesPage() {
const cases = await listCases();
return ;
}
No "use client", no useEffect, no Server Action.
2. Stream a promise to a Client Component with use() + ``
// app/(app)/cases/page.tsx — Server Component
import { Suspense } from "react";
import { listCases } from "@/lib/services/case.service";
import CasesTable from "./_components/cases-table"; // "use client"
export default function CasesPage() {
const casesPromise = listCases(); // do NOT await
return (
}>
);
}
// _components/cases-table.tsx
"use client";
import { use } from "react";
export default function CasesTable({
casesPromise,
}: {
casesPromise: Promise;
}) {
const cases = use(casesPromise); // suspends until resolved
// …interactive UI
}
The Server Component starts the fetch, streams HTML as soon as it can, the Client Component hydrates with the resolved value. No client-side waterfall.
3. Server Action — only for mutations, invoked via `` or event handler after user intent
// lib/actions/cases.actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function archiveCase(id: string) {
await caseService.archive(id);
revalidatePath("/cases");
}
After the mutation, invalidate and let the Server Component re-render with fresh data. Don't return a list to refresh client state by hand.
Variants — pick the narrowest:
revalidatePath('/cases')— invalidate by route segment.revalidateTag('cases')— invalidate by tag (when a service usesfetch(..., { next: { tags: ['cases'] } }),unstable_cache'stagsoption, or — under Cache Components —cacheTag('cases')inside a"use cache"function/component).refresh()fromnext/cache— inside a Server Action, refresh the client router cache for the current route. Useful when the mutation happens on the same page. Does not revalidate tagged data by itself — pair it withrevalidateTag/updateTagwhen the mutation also needs to invalidate a tag.
revalidateTag signature depends on whether Cache Components (cacheComponents: true) is enabled — see [Cache Components](#cache-components--use-cache-next-16-opt-in) below for the full breakdown:
- Cache Components off (default / pre-16 caching model):
revalidateTag('cases')— single argument, expires the tag immediately. This is what the example above uses and it's correct for projects that haven't opted into Cache Components. - Cache Components on: the single-argument form is deprecated. Use either:
revalidateTag('cases', 'max')in a Server Action or Route Handler — stale-while-revalidate: the current request still gets a fast (possibly stale) response, fresh data loads in the background.updateTag('cases')— Server Actions only, immediate expiry, read-your-own-writes (the user who triggered the mutation sees the new value on the very next render, not a background refresh). Prefer this overrevalidateTaginside Server Actions whenever the user needs to see their own change immediately (e.g. after creating or editing the record they're looking at).
4. Route Handler + TanStack Query (recommended) / SWR — last resort, narrow scope
Reach for this only when the data genuinely changes without user intent: interval polling, focus revalidation, third-party mutates the data outside your app. Anything else belongs in patterns 1–3.
TanStack Query is the default for this rung — it's what the project already has if forms scaffolded stack.forms = "tanstack-form", and it gives you retries, request dedup, devtools, and mutation helpers for free. Reach for SWR only for a genuinely trivial one-off polling widget in a project that has no other client-side query library; never install both.
// app/api/dashboard/stats/route.ts — only because the dashboard polls every 5s
import { NextResponse } from "next/server";
export async function GET(req: Request) {
const range = new URL(req.url).searchParams.get("range") ?? "30d";
return NextResponse.json(await getDashboardStats(range));
}
// TanStack Query — recommended default
"use client";
import { useQuery } from "@tanstack/react-query";
export function LiveStats({ range }: { range: string }) {
const { data } = useQuery({
queryKey: ["dashboard-stats", range],
queryFn: () =>
fetch(`/api/dashboard/stats?range=${range}`).then((r) => r.json()),
refetchInterval: 5_000,
});
return /* … */;
}
// SWR — acceptable for a single trivial polling widget, no other client query lib in the project
"use client";
import useSWR from "swr";
export function LiveStats({ range }: { range: string }) {
const { data } = useSWR(`/api/dashboard/stats?range=${range}`, fetcher, {
refreshInterval: 5_000,
});
return /* … */;
}
If your Client Component doesn't poll, doesn't refetch on focus, and isn't watching externally-mutated data — you don't need this.
Cache Components / use cache (Next 16, opt-in)
Next 16 introduces Cache Components, the explicit opt-in caching model, enabled with cacheComponents: true in next.config.ts. Once on, nothing is cached unless you mark it with the "use cache" directive — the framework stops implicitly caching and you cache deliberately. This is orthogonal to the ladder above: you still default to async Server Components; "use cache" is for expensive reads you want memoized across requests (a slow aggregate query, a third-party API call, a rarely-changing config), not a replacement for RSC data fetching.
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = { cacheComponents: true };
export default nextConfig;
// lib/services/case.service.ts — cache an expensive read
export async function getCaseStats() {
"use cache"; // file/function/component-level directive
cacheLife("hours"); // how long this stays fresh (built-in profile or custom)
cacheTag("cases"); // tag so a mutation can invalidate it
return db.select(/* expensive aggregate */);
}
cacheLife(profile)— the freshness/expiry profile ("seconds"|"minutes"|"hours"|"days"|"max", or a custom profile innext.config.ts).cacheTag(tag)— attaches a tag; a Server Action then invalidates it.
Invalidation under Cache Components (this is why the mutation section above branches on the flag):
updateTag('cases')— Server Actions only, immediate expiry with read-your-own-writes. Prefer it when the user must see their own change on the next render (e.g. right after editing the record they're viewing).revalidateTag('cases', 'max')— the second argument (a cache profile) is required here; it's stale-while-revalidate (the triggering request may still see stale data while fresh loads in the background). The single-argumentrevalidateTag('cases')is the pre-Cache-Components form and is deprecated oncecacheComponentsis on.
If the project has not enabled cacheComponents, ignore this section: the single-argument revalidatePath / revalidateTag in the mutation examples above are correct as-is. [VERIFY] the exact cacheLife profile names and the revalidateTag/updateTag signatures against the installed Next version — this surface is new in 16 and still settling.
Anti-pattern catalog — red ❌ → green ✅
The full red→green catalog (6 patterns: action-in-useEffect, useState-filter, manual-refetch-after-mutation, getX-in-actions, await-then-pass-to-client, optimistic-by-hand) is in references/anti-patterns.md. Brief index:
- Reading via Server Action in
useEffect→ async Server Component. - Filter / tab state in
useState, refetched via action → URLsearchParams+ Server Component re-render. - Manual re-read after mutation →
revalidatePath/revalidateTag/refreshinside the actio
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: lukedj78
- Source: lukedj78/dev-flow
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.