6. modulÁllapotkezelés és SSR-csapdák
Nuxt for Devs · 6. modul

Állapotkezelés és az SSR-csapdák

Először egy kérdés, ami a legtöbb állapotkezelési problémát megelőzi: hova tartozik ez az adat? Utána a useState, a Pinia mérlege, a cookie- és URL-alapú állapot — és a modul közepén a cross-request state pollution teljes anatómiája, mert Workers-en ez a legkönnyebben elkövethető, legnehezebben észrevehető hiba.

6.1Hova tartozik ez az adat?

Vue SPA-ban egy helyre szokott kerülni minden: a store-ba. SSR mellett ez pazarlás és hibaforrás egyszerre, mert sok „állapot” valójában nem állapot. Mielőtt bármit useState-be tennél, fusd át ezt:

Ha ez……akkor ide tartozikMiért
Csak egy komponensen belül számít (modál nyitva, gépelt szöveg)ref() a komponensbenNem kell megosztani, nem kell túlélnie semmit.
A szerverről jön, és a szerver az igazság forrása (projektek, felhasználó adatai)useFetch / useAsyncDataEz nem állapot, hanem gyorsítótárazott szerveri adat. Kulcsa van, frissíthető (5. modul).
Megosztható és a vissza-gombnak működnie kell (szűrők, lapozás, rendezés)URL query-paraméterMegosztható link, működő előre/vissza, SSR-ben azonnal helyes.
A szervernek is látnia kell az első renderelésnél (téma, nyelv)useCookieA cookie a kéréssel utazik, tehát az SSR is látja — nincs hydration mismatch.
Bizalmas vagy hitelesítéshez tartozik (session)Szerveroldali session + httpOnly cookieA kliens nem is olvashatja. (10. modul)
Több komponens osztozik rajta, és túl kell élnie az SSR-t (bejelentkezett felhasználó, aktuális tenant)useState('kulcs')Ez az, ami tényleg globális kliensállapot.
A leggyakoribb hiba nem az, hogy rossz eszközt választasz, hanem hogy túl sokat teszel az állapotba. Ha a szűrőket useState-ben tartod, elveszted a megosztható linket és a vissza-gombot. Ha a szerveri listát useState-be másolod, két igazságforrásod lesz, és neked kell szinkronban tartanod őket. Kérdezd meg minden adatnál: mi az igazság forrása?

6.2useState a gyakorlatban

// app/composables/useCurrentUser.ts
export const useCurrentUser = () => {
  // az initializer CSAK akkor fut, ha ehhez a kulcshoz még nincs érték
  return useState<User | null>('auth:user', () => null)
}

Három dolog, amit érdemes tudni róla:

// „store" Nuxt-módra — nem kell hozzá könyvtár
export const useSidebar = () => {
  const open = useState('ui:sidebar', () => true)
  const toggle = () => { open.value = !open.value }
  return { open, toggle }
}

// takarítás — például kijelentkezéskor vagy tenantváltásnál
clearNuxtState(['auth:user', 'tenant:current'])

6.3Cross-request state pollution — az anatómia

Ez a modul lényege. A 3. modulban már felvillantottuk; most nézzük meg, miért történik, és hol bújik el.

✗ MODUL-SZINTŰ ref — EGY PÉLDÁNY MINDEN KÉRÉSNEK Kérés A · Acme Kérés B · Globex egyetlen izolátum · modul-scope const items = ref([]) ugyanaz a tömb — Acme és Globex is ebbe ír Globex látja az Acme adatait ✓ useState / event.context — KÉRÉSENKÉNT KÜLÖN Kérés A · Acme Kérés B · Globex ugyanaz az izolátum, de kérés-hatókörű állapot A: useState('cart') → [ … ] B: useState('cart') → [ … ] mindenki a sajátját látja
Az izolátum hosszú életű, és sok kérést szolgál ki. Ami modul-szinten van, azon osztoznak — ami kérés-hatókörben, azon nem.

Miért nem veszed észre?

Mert a hiba eltűnik, amikor keresed. Fejlesztés közben egyedül vagy: egy kérés, egy állapot, minden rendben. Az első éles napokon is működhet, ha kevés a forgalom, és az izolátum kérések között „kipiheni magát”. Aztán jön egy nap, amikor két felhasználó egyszerre kattint, és valaki más adatait látja.

Workers-en ez különösen éles. Az izolátumok hosszú életűek és újrahasznosulnak: ugyanaz az izolátum sok kérést szolgál ki egymás után, és több kérést párhuzamosan is. Ezért szerepel a Cloudflare hivatalos Workers best practices listáján ez a sor: „Avoid global mutable state — Workers reuse isolates across requests. Storing request-scoped data in module-level variables causes cross-request data leaks.” Ez nem Nuxt-specifikus tanács, hanem a platform alapszabálya — a Nuxt csak sok helyet ad, ahol elkövetheted.

A három rejtekhely

① App-oldal: modul-szintű reaktív állapot
// app/composables/useFilters.ts
const filters = reactive({ status: 'active' })   // ✗ minden kérés ugyanezt írja
export const useFilters = () => filters
② Szerver-oldal: modul-szintű változó a server/utils-ban
// server/utils/context.ts
let currentTenant: Tenant | null = null            // ✗ a legveszélyesebb változat

export function setTenant(t: Tenant) { currentTenant = t }
export function getTenant() { return currentTenant }

Ez a második a legrosszabb, mert kényelmesnek tűnik („ne kelljen mindenhova átadni az event-et”), és mert szerveroldalon van, ahol a valódi adat lakik. Két párhuzamos kérésnél az egyik felülírja a másik tenantját, és a lekérdezések a rossz tenanttal futnak le. A helyes megoldás a 4. modulból: event.context.tenant, és az event-et végig átadod.

③ „Cache”, ami nem kulcsolt
// server/utils/settings.ts
let cached: Settings | null = null

export async function getSettings(event) {
  if (!cached) cached = await loadSettings(event)   // ✗ melyik tenanté?!
  return cached
}

A cache önmagában nem baj — de a kulcsának tartalmaznia kell mindent, amitől az érték függ. Ha tenantonként más, akkor Map<tenantId, Settings> kell, és még akkor is korlátos élettartammal. A jó megoldás általában nem is memóriában van: KV vagy a Nitro cache-rétege, tenantra kulcsolva (18. modul).

Mit szabad modul-szinten tartani?

SzabadNem szabad
Konstansok, enumok, séma-definíciók
Tiszta függvények
Előre lefordított regexek, validátorok
Immutábilis konfigurációs objektumok
Bármi, ami felhasználóhoz vagy tenanthoz köthető
Kérésenként átírt objektum vagy tömb
„Aktuális” bármi (currentUser, currentTenant)
Kulcs nélküli cache
Egy Cloudflare-specifikum, ami erre rásegít: Workers-en a bindingok (D1, R2, KV, Hyperdrive) nem elérhetők modul-szinten — csak a kérés kontextusából, az env-en keresztül. Vagyis a platform fizikailag megakadályozza, hogy modul-szinten építs adatbázis-klienst; helyette kérésenként kell felépítened az event-ből. Ez elsőre kényelmetlen, valójában viszont pont a helyes irányba terel. A 11. modulban megcsináljuk a wrappert, ami ezt elrejti.

Hogyan találod meg?

  1. Kódszabály: a server/ alatt és a composables/-ben ne legyen olyan let, reactive() vagy ref() a fájl tetején (export-függvényen kívül), ami nem konstans. Ezt code review-ban kell nézni — lintszabállyal is kikényszeríthető.
  2. Teszt párhuzamos kérésekkel: két különböző tenanttal, egyszerre indított kérés, és nézd, hogy mindkettő a sajátját kapja-e. Egy egyszerű Promise.all két $fetch-csel már kimutatja a durva eseteket.
  3. Preview-környezetben, valódi Workers-runtime alatt. Lokálisan, Node-ban ritkán reprodukálódik — a 2. modul dev-vs-prod táblája erről szólt.

6.4Mikor kell tényleg Pinia?

A Pinia jó könyvtár, és a @pinia/nuxt modullal SSR-biztos: a store-ok kérésenként példányosulnak, az állapot a payloadon keresztül jut a kliensre. Nem kell félni tőle — csak nem az első választás.

Maradj useState-nél, ha…Válts Piniára, ha…
Néhány globális érték van (felhasználó, tenant, téma)
Az adat többsége úgyis useFetch-ből jön
Nem kell nyomkövetni az állapot változásait
Összetett domain-logika sok actionnel és getterrel
Store-ok épülnek egymásra
Kell a devtools időutazás és az akciónapló
A csapat már ismeri, és van rá kialakult mintátok
A Pinia sem véd a modul-scope hibától. Ha a defineStore kívül hozol létre reaktív értéket, és onnan hivatkozol rá, akkor pontosan ugyanazt a szivárgást csinálod, mint a 6.3-ban. A Pinia attól SSR-biztos, hogy a store-példányokat a Nuxt-plugin kérésenként hozza létre — nem attól, hogy Piniának hívják. És ugyanaz a payload-szabály is él rá: amit a store-ban tartasz a szerveren, az kimegy a HTML-be.

6.5Cookie-alapú állapot: useCookie

Emlékszel a 3. modul hydration-táblájából a localStorage-sorra? Ez a megoldás rá. A cookie a kéréssel együtt utazik, tehát a szerver már az első rendereléskor látja — nincs eltérés a szerveri és a kliensi kimenet között:

// app/composables/useTheme.ts
export const useTheme = () => {
  return useCookie<'light' | 'dark'>('theme', {
    default:  () => 'light',
    maxAge:   60 * 60 * 24 * 365,
    sameSite: 'lax',
    secure:   true,
    path:     '/',
  })
}

// használat — reaktív ref, írásra a böngésző cookie-ját is frissíti
const theme = useTheme()
theme.value = 'dark'

Amire figyelj:

6.6Az URL is állapot

Szűrők, rendezés, lapozás, kijelölt fül: ezek majdnem mindig az URL-be valók, nem useState-be. Négy dolgot kapsz ingyen: megosztható linket, működő vissza-gombot, azonnal helyes SSR-t, és cache-elhetőséget.

<script setup lang="ts">
const route  = useRoute()
const router = useRouter()

// az URL az igazság forrása
const status = computed({
  get: () => (route.query.status as string) ?? 'active',
  set: (v) => router.replace({ query: { ...route.query, status: v, page: undefined } }),
})

// és a lekérés egyszerűen követi
const { data: projects } = await useFetch('/api/projects', {
  query: { status },
  key: () => `projects:${slug.value}:${status.value}`,
})
</script>
Egy jó gyakorlati szabály: ha a felhasználó azt mondaná, hogy „küldd át nekem ezt a nézetet”, akkor az URL-ben a helye. Ha azt mondaná, hogy „ez az én beállításom minden nézetben”, akkor cookie-ban. Ha csak most, ezen a képernyőn számít, akkor ref.

6.7Állapot a tenant-határon

Ha az appodban lehet tenantot váltani anélkül, hogy teljes oldalújratöltés történne, akkor a váltás egy invalidálási esemény: minden tenanthoz kötött állapotot és lekért adatot el kell dobni.

export async function switchTenant(slug: string) {
  // ① minden tenanthoz kötött kliensállapot törlése
  clearNuxtState(['tenant:current', 'ui:recent', 'billing:summary'])

  // ② a lekért adatok eldobása — ha a kulcsaid tenant-prefixesek, célzottan is megy
  await clearNuxtData((key) => key.includes(':'))

  // ③ és a navigáció — aldomain-modellben ez amúgy is teljes újratöltés
  await navigateTo(`/t/${slug}`)
}
A legegyszerűbb és legbiztonságosabb megoldás: a tenantváltás legyen teljes oldalbetöltés (navigateTo(url, { external: true }) vagy egyszerűen egy sima link). Ezzel minden kliensállapot eltűnik, és nem kell emlékezned rá, mit felejtettél el invalidálni. Aldomain- és egyedi domain-modellben ez amúgy is automatikus, mert más az origin. Az „elegáns” kliensoldali tenantváltás a leggyakoribb forrása annak, hogy egy pillanatra a másik munkaterület adata villan fel.

6.8Ellenőrizd magad

  1. Miért nem elég azt mondani, hogy „a szerveren minden kérés új kontextust kap”?
    Válasz

    Mert a modul-scope nem része a kérés-kontextusnak. A modul egyszer értékelődik ki az izolátum élettartama alatt, és az összes kérés ugyanazokat a modul-szintű változókat látja. A kérés-hatókörű dolgokat kifejezetten kérés-hatókörű helyre kell tenni: useState az app oldalon, event.context a szerveren.

  2. Miért veszélyesebb Workers-en ez a hiba, mint egy hagyományos Node-szerveren?
    Válasz

    Mert az izolátumok hosszú életűek, újrahasznosulnak a kérések között, és egyszerre több kérést is kiszolgálhatnak. Node-nál is létező hiba, de ott a szokásos processz-per-instance felállás és a rövidebb élettartamok néha elfedik. A Cloudflare ezért teszi be a hivatalos Workers best practices listájába: „avoid global mutable state”.

  3. Egy server/utils/context.ts-ben van egy let currentTenant, amit middleware-ből állítasz be. Mi a baj, és mi helyette a helyes?
    Válasz

    Két párhuzamos kérésnél az egyik felülírja a másik tenantját, így lekérdezések futhatnak a rossz tenanttal — ez a legsúlyosabb változata a hibának, mert az adatréteget érinti. Helyette: event.context.tenant a szerver middleware-ben, és az event-et add át végig a hívási láncon.

  4. A felhasználó szűri a projektlistát „csak aktív”-ra, majd elküldi a linket egy kollégának. Hol tárolod a szűrőt, és miért?
    Válasz

    Az URL query-paraméterében. Így a link megosztható, a vissza-gomb működik, az SSR már az első rendereléskor a helyes listát adja, és a válasz cache-elhető marad. useState-ben tárolva mindezt elveszted.

  5. Mit teszel useState-be, és mit nem?
    Válasz

    Beleteszed: komponensek közt megosztott, az SSR-t túlélő kliensállapotot — bejelentkezett felhasználó, aktuális tenant, UI-preferencia. Nem teszed bele: szerveri adatot (az useFetch dolga), URL-be való nézetállapotot, bizalmas mezőt (a payloadba kerül), és függvényt vagy osztálypéldányt (nem szerializálható).

  6. Mi a legbiztonságosabb módja a tenantváltásnak, és miért?
    Válasz

    Teljes oldalbetöltés (külső navigáció vagy sima link). Ezzel minden kliensállapot és minden lekért adat eltűnik, tehát nem kell emlékezni rá, mit kellett volna invalidálni. A kliensoldali tenantváltásnál minden felejtésből egy pillanatnyi „a másik munkaterület adata villan fel” lesz — és ha a kulcsaid nem tenant-prefixesek, akkor nem is csak pillanatnyi.

Előző5. modul — Adatlekérés — a kulcsmodul Következő 7. modul — Renderelési módok és routeRules