useFetch, useAsyncData, $fetchA tananyag legfontosabb modulja. Itt dől el, hogy az appod kétszer kérdezi-e le ugyanazt az adatot, hogy villog-e betöltéskor — és itt van elrejtve az a csapda, amivel más tenant adatát szivárogtathatod ki a HTML-be anélkül, hogy valaha kirendernéd.
Elsőre zavaró, hogy három dolog is „adatot kér le”. Valójában rétegek:
| Eszköz | Mi ez | SSR-tudatos? |
|---|---|---|
$fetch | A HTTP-kliens (az ofetch). Kér egyet, visszaad egy promise-t. Semmi több. | Nem |
useAsyncData | Az SSR-réteg: kulccsal ellátja az eredményt, átviszi a szerverről a kliensre, kezeli a betöltési állapotot és a dedupot. Bármilyen async függvényt elfogad, nem csak HTTP-t. | Igen |
useFetch | A kettő egyben: useAsyncData + $fetch, automatikusan generált kulccsal. A napi eszközöd. | Igen |
<script setup>
const projects = await $fetch('/api/projects')
</script>
Ez működni fog — és ettől a legrosszabb. Ami történik: a setup lefut a szerveren, lekéri az adatot, kirendereli a HTML-t. Aztán a böngészőben, a hydration alatt ugyanez a setup újra lefut, és mivel a $fetch-nek fogalma sincs a szerveren már megtörtént lekérésről, megint lekérdezi. Kétszeres adatbázis-terhelés, plusz egy fölösleges kör a böngészőből.
<script setup>
const { data: projects, status, error } = await useFetch('/api/projects')
</script>
| Helyzet | Ezt használd |
|---|---|
Az oldal adata, komponens setup-ban | useFetch |
| Nem HTTP-forrás (SDK, binding, több hívás összefésülve) | useAsyncData('kulcs', () => …) |
| Eseménykezelő: gombnyomás, űrlapküldés, törlés | $fetch |
server/api handleren belül | $fetch (belső útvonalra közvetlen hívás lesz) |
// eseménykezelőben $fetch — itt nincs SSR, nincs mit átvinni
async function archive(id: string) {
await $fetch(`/api/projects/${id}/archive`, { method: 'POST' })
await refresh() // a useFetch-ből kapott refresh
}
Minden useAsyncData/useFetch hívásnak van egy kulcsa. Ez azonosítja az eredményt a payloadban, ezen keresztül talál rá a kliens a szerveren lekért adatra, és ez alapján osztozik két komponens ugyanazon az adaton.
A useFetch automatikusan generál kulcsot (a hívás helye és az URL alapján). Ez a legtöbbször jó — de amint az URL dinamikus, érdemes átvenni az irányítást:
<script setup lang="ts">
const route = useRoute()
// reaktív URL: ha az id változik, automatikusan újratölt (Nuxt 4: reaktív kulcsok)
const { data: project } = await useFetch(
() => `/api/projects/${route.params.id}`,
{ key: () => `project:${route.params.id}` },
)
</script>
Két képesség, amit a kulcs ad:
useNuxtData('project:42').refreshNuxtData('project:42') csak azt az egy bejegyzést tölti újra, nem az egész oldalt.const { slug } = useTenant()
const filters = ref({ status: 'active' })
const { data: projects } = await useFetch('/api/projects', {
query: filters,
key: () => `projects:${slug.value}:${filters.value.status}`,
watch: [slug, filters],
})
Ezt a táblát fogod visszakeresni. A félkövérek azok, amiket hetente használsz:
| Opció | Mit csinál | Mikor |
|---|---|---|
key | Az eredmény azonosítója. Lehet getter is (reaktív kulcs). | Amint dinamikus az URL. |
lazy | true esetén nem blokkolja a navigációt — az oldal azonnal megjelenik, az adat később érkezik. | Másodlagos tartalomra (oldalsáv, statisztika). A fő tartalomra ne. |
server | false esetén csak a kliensen fut — nem kerül a payloadba. | Személyre szabott vagy bizalmas részekre, amit nem akarsz a HTML-ben. |
watch | Reaktív források, amik változásra újratöltenek. | Szűrők, lapozás, tenantváltás. |
transform | Átalakítja az eredményt, mielőtt a payloadba kerül. | Formázás és — fontos — a fölös mezők kidobása. |
pick | Csak a felsorolt kulcsokat tartja meg. | Ugyanaz egyszerűbben, ha lapos az objektum. |
enabled | Kapuzza a futást — ha false, nem indul el (4.5). | „Csak akkor kérd le, ha már megvan a session.” |
immediate | false esetén csak kézi execute()-ra fut. | Gombra induló lekérés. |
default | Kezdőérték, amíg nincs adat. | () => [], hogy ne kelljen v-if mindenhova. |
dedupe | 'cancel' (alap) vagy 'defer' párhuzamos hívásoknál. | Gyorsan gépelt keresőmezőnél. |
getCachedData | Saját cache-logika: honnan vegye, ha már megvan. | Navigáció közti megőrzés — óvatosan (lásd lentebb). |
const {
data, // az eredmény (ref)
status, // 'idle' | 'pending' | 'success' | 'error' ← ezt használd a sablonban
error, // a hiba, ha volt
refresh, // újratöltés
execute, // ugyanaz, immediate:false mellett használatos
clear, // data/error/status visszaállítása
} = await useFetch('/api/projects')
<template>
<ProjectSkeleton v-if="status === 'pending'" />
<ErrorBox v-else-if="status === 'error'" :error="error" />
<ProjectList v-else :items="data" />
</template>
A két eszköz máshogy viselkedik hibánál, és ez rendszeresen meglep:
useFetch nem dob kivételt — a hibát az error ref-be teszi, és a status lesz 'error'. Neked kell megjelenítened.$fetch dob — try/catch kell köré. A hiba FetchError, a szerver válasza a err.data-ban van.// szerveroldalon így keletkezik a hiba:
throw createError({
statusCode: 404,
statusMessage: 'Nincs ilyen projekt', // ← ezt LÁTJA a kliens
data: { field: 'id' }, // ← ez is átmegy, ha megadod
message: 'projects.findFirst returned null for tenant 42', // ← ez NEM megy ki élesben
})
// kliensoldalon, eseménykezelőben:
try {
await $fetch('/api/projects', { method: 'POST', body })
} catch (e: any) {
if (e.statusCode === 422) formErrors.value = e.data?.errors
else toast.error('Nem sikerült létrehozni')
}
Ha egy hiba az egész oldalt érvényteleníti (nincs ilyen projekt, nincs jogosultság), akkor fatal: true-val vagy showError()-ral az app/error.vue-ra dobod — ez a 4. modulban volt.
Amikor a useFetch a szerveren lefut, az eredményt valahogy át kell adni a kliensnek, hogy az ne kérje le újra. Ez a payload: a Nuxt beleírja az eredményt a HTML-be, egy <script id="__NUXT_DATA__"> elembe.
Nézd meg magad: nyiss meg egy Nuxt-oldalt, és a forrást nézd (nem a DevTools Elements fülét, mert az már a hidratált DOM). A HTML alján ott lesz az összes szerveroldali lekérésed eredménye — nyersen, JSON-szerű formában.
belso_megjegyzes, a beszerzesi_ar, a tulajdonos_email és a tenant_id is kimegy a böngészőbe — akkor is, ha a sablonban csak a nevet írod ki. Ez nem elméleti: ez az egyik leggyakoribb adatszivárgási forma SSR-es appokban.// server/api/projects/[id].get.ts
export default defineEventHandler(async (event) => {
return await useDb(event).query.projects.findFirst({
where: eq(projects.id, getRouterParam(event, 'id')!),
with: { owner: true }, // ← és a teljes user-sor is, jelszó-hash-sel együtt
})
})
export default defineEventHandler(async (event) => {
const { tenantId } = await requireSession(event)
const p = await useDb(event).query.projects.findFirst({
where: and(
eq(projects.id, getRouterParam(event, 'id')!),
eq(projects.tenantId, tenantId), // a tenant-szűrés MINDIG a lekérdezésben
),
with: { owner: { columns: { id: true, name: true, avatarUrl: true } } },
})
if (!p) throw createError({ statusCode: 404, statusMessage: 'Nincs ilyen projekt' })
// és a válasz alakja is explicit — nem a DB-séma szivárog ki az API-ba
return {
id: p.id, name: p.name, status: p.status, updatedAt: p.updatedAt,
owner: { id: p.owner.id, name: p.owner.name, avatarUrl: p.owner.avatarUrl },
}
})
pick és transformEzek a kliensoldali hívásnál szűkítik a payloadot — a szerveren futnak le, mielőtt a szerializálás megtörténik, tehát tényleg csökkentik, ami kimegy:
const { data } = await useFetch('/api/projects', {
pick: ['id', 'name', 'status'],
})
// vagy összetettebb esetben:
const { data } = await useFetch('/api/projects', {
transform: (rows) => rows.map(r => ({ id: r.id, name: r.name })),
})
pick/transform jó a payload-méret csökkentésére, de az adat így is átment a szerveri renderelőn, és ha bármikor server: false-ra váltasz vagy kliensről hívod ugyanazt a végpontot, a teljes objektum kimegy a dróton. A helyes szabály: a server route soha ne adjon vissza nyers adatbázis-sort. A pick a második védvonal, nem az első.tenant_id-t, ami már önmagában is felderítést tesz lehetővé.user-sorral ez véletlenül is bekerül.deleted_at elárulja, mit töröltek és mikor.Ha valami tényleg csak a bejelentkezett felhasználónak szól és nem kell SSR-ben, a server: false a helyes eszköz — akkor a lekérés kizárólag a böngészőben történik, és semmi nem kerül a HTML-be:
// személyre szabott, nem indexelendő, nem cache-elhető rész
const { data: notifications } = await useFetch('/api/me/notifications', {
server: false,
lazy: true,
})
server: false-szal kliensre teszed, a váz cache-elhetővé válik. Ez a 18. modul fő gondolata; itt csak annyi, hogy a cache-elhetőség már ebben a modulban eldől.A soros await-ek vízesést csinálnak: a második lekérés csak az első befejeződése után indul el.
const { data: user } = await useFetch('/api/me')
const { data: projects } = await useFetch('/api/projects')
const { data: usage } = await useFetch('/api/usage')
const [{ data: user }, { data: projects }, { data: usage }] = await Promise.all([
useFetch('/api/me'),
useFetch('/api/projects'),
useFetch('/api/usage'),
])
// server/api/dashboard.get.ts — egy adatbázis-menet, egy válasz
export default defineEventHandler(async (event) => {
const { userId, tenantId } = await requireSession(event)
const db = useDb(event)
const [projects, usage] = await Promise.all([
db.query.projects.findMany({ where: eq(projects.tenantId, tenantId), limit: 10 }),
getUsage(db, tenantId),
])
return { projects: projects.map(toProjectDto), usage }
})
useFetch('/api/…') belső hívás — a Nitro közvetlenül meghívja a handlert, nem indít HTTP-kérést (3. modul). Tehát a fenti három hívás nem fogyaszt subrequestet. Ami viszont igen számít: a Workers-runtime egyszerre 6 kimenő kapcsolatot tart nyitva, tehát ha a server route-jaid külső API-kat hívnak, ott a párhuzamosításnak van felső határa. És a CPU-idő is mérve van: a vízeséstől nem lesz több CPU-fogyasztás, de a válaszidő igen — a felhasználó azt érzi meg.getCachedData — hasznos, de vigyázz veleAlapból a useAsyncData minden navigációnál újratölt. A getCachedData-val megmondhatod, hogy ha már megvan az adat, ne kérje le újra:
const { data } = await useFetch('/api/settings', {
key: () => `settings:${slug.value}`,
getCachedData: (key, nuxtApp, ctx) => {
// kézi frissítéskor mindig menjen új kérés
if (ctx.cause === 'refresh:manual') return undefined
return nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
},
})
getCachedData kulcsa nem tartalmazza a tenantot (és a felhasználót), akkor a cache-ed a tenant-határon átnyúlik. A gyakorlati szabály ugyanaz, mint a kulcsnál: ami befolyásolja a választ, az legyen a kulcsban. Ha bizonytalan vagy, ne használd — az újratöltés olcsóbb, mint egy adatszivárgás.useFetch-csel jön (nem $fetch-csel a setup-ban)?server: false-szal jönnek, hogy a HTML cache-elhető maradjon?__NUXT_DATA__-ban?useState részletesen, mikor kell tényleg Pinia, és a cross-request state pollution teljes anatómiája — az a hiba, amit a 3. modulban már felvillantottunk, de amit érdemes egyszer rendesen megérteni, mert Workers-en különösen könnyű elkövetni.const data = await $fetch('/api/x') egy komponens setup-jában?
Mert a $fetch nem SSR-tudatos: nincs kulcsa, és az eredménye nem kerül a payloadba. A setup lefut a szerveren (lekérés #1), majd a hydration alatt a böngészőben újra (lekérés #2). Dupla adatbázis-terhelés és egy fölösleges hálózati kör. Helyette useFetch.
A szerveroldali useAsyncData/useFetch eredményeinek szerializált másolata, amit a Nuxt beleír a HTML-be egy <script id="__NUXT_DATA__"> elembe, hogy a kliensnek ne kelljen újra lekérnie. Megnézni a lap forrásában lehet (nem a DevTools Elements fülén, mert az már a hidratált DOM-ot mutatja).
Mert a payloadba a server route teljes válasza kerül, nem az, amit kirenderelsz. Ha a végpont a nyers adatbázis-sort adja vissza, minden mezője kimegy a HTML-be. Megoldás: a server route explicit DTO-t adjon vissza; másodlagosan pick/transform a lekérésnél.
Ugyanazt a key-t mindkét helyen — a Nuxt így megosztja az eredményt, és csak egyszer kér le. Máshonnan az adat useNuxtData('kulcs')-csal is elérhető, célzottan frissíteni pedig refreshNuxtData('kulcs')-csal lehet.
server: false-t, és mit nyersz vele?
Személyre szabott, nem indexelendő tartalomnál — értesítések, „legutóbb megnyitott”, felhasználó-specifikus ajánlások. Két dolgot nyersz: nem kerül a HTML payloadjába (nincs szivárgás), és a HTML váza cache-elhetővé válik, mert nincs benne felhasználó-specifikus adat.
rows.filter(r => r.tenantId === t))?
Mert az adat így már kijött az adatbázisból: fölösleges terhelés, és egyetlen elfelejtett szűrés az egész más tenant adatát kiadja. A tenant-feltétel a lekérdezés WHERE-ágába való, minden egyes lekérdezésben. A szűrés a válaszon nem védelem, csak kozmetika.