State: useState, Pinia and cookies
Share state per visitor with useState(), reset it with clearNuxtState(), keep choices across reloads with useCookie(), and move to a Pinia store when state needs actions.
What you'll learn
When you finish, you can do these.- 1Give each visitor their own promo code with useState()
- 2Reset the promo to its starting value with clearNuxtState()
- 3Keep a currency choice across reloads with useCookie()
- 4Keep a cart with its actions and totals in a Pinia store
The Corner Shop, the small online shop this track builds, keeps three kinds of state, values it remembers, for each visitor. A promo code from a newsletter link should last for the visit. The currency a visitor picks should still be set tomorrow. The cart badge in the header must count what the cart page lists. In plain Vue, each could sit in a ref, a value that updates the page when it changes, at the top of a .ts file. In Nuxt, that ref can show one visitor's data to another, because one server program renders the pages for everyone.
The Code tab holds the shop's files: the promo code in Nuxt's useState(), the currency in a cookie and the cart in a Pinia store. You need one idea from the lesson SSR and hydration: on a first page load, the server sends the HTML with the data it used, in a payload. The browser reads that payload when it takes the page over, a step called hydration.
One value per visitor with useState
useState() gives each visitor their own value, such as the promo code. A ref at the top of a .ts file cannot: the server loads each of your files (JavaScript modules) once, so that ref is created once, and every request reads and writes it. This is cross-request state pollution. A component's <script setup> runs again for each visitor, so its refs are safe.
The shop wraps useState in a composable, a function named use… that components call: usePromo().
export function usePromo() {
return useState('promo', () => ({ code: '', percent: 0 }))
}
The key, 'promo', names the value in the payload. The function after it, init, returns the starting value, no code and 0 percent off, and the server runs it for each request. Every component on that visitor's page that calls usePromo() gets the same ref, and a full reload starts it again from init.
The home page, app/pages/index.vue, copies the promo code from a newsletter link such as /?promo=SPRING10 into that ref, and its banner reads "Code SPRING10: 10% off at checkout".
The payload is text, so a useState value must be plain data, such as strings, numbers, arrays and plain objects, never a class instance or a function.
The cart page, app/pages/cart.vue, has a Remove code button. Its handler calls clearNuxtState(), which clears a useState value:
function removeCode() {
clearNuxtState('promo', { reset: true })
}
With { reset: true }, Nuxt runs init again: the line "Code SPRING10 applied" disappears and the total returns to the full price. Without it, clearNuxtState('promo') deletes the value, the ref holds undefined, and the next render fails with TypeError: Cannot read properties of undefined (reading 'code').
Cookies for choices that outlive the page
The currency has to survive a reload, and the server has to know it before it renders a price. A cookie, a small value the browser stores and sends with every request, does both. useCookie() returns one as a ref: assign to the ref and Nuxt sets the cookie. The shop's useCurrency() keeps the currency, GBP or EUR, in one:
export function useCurrency() {
return useCookie<Currency>('currency', {
default: () => 'GBP',
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
})
}
The Currency menu in the header, in app/app.vue, is tied to this ref with v-model. Choosing EUR sets the cookie, and on a reload the server renders euro prices from the start.
maxAge is the lifetime in seconds, here a year. Without it or an expires date, the browser deletes the cookie when it closes. sameSite: 'lax' keeps it off most requests that other sites start.
The visitor can edit a cookie, so keep a currency there, never a price or a permission.
Pinia when state has behaviour
The cart needs more than a value. It has actions (add, remove), numbers worked out from it (count, subtotal) and two readers: the cart page and CartBadge.vue, the header badge that shows Cart (0). That is a store's job: one place any component reaches by calling one function, here useCartStore().
Pinia is Vue's official store library. npx nuxt module add pinia installs it with its Nuxt add-on, @pinia/nuxt, and lists the add-on in nuxt.config.ts.
The add-on gives every request fresh stores and sends their state in the payload, as useState does. It also auto-imports defineStore and every store in app/stores/.
The cart store, app/stores/cart.ts, is a setup store, written as one function: its refs are the state, its computed values are getters and its functions are actions. Here it is with one of each:
export const useCartStore = defineStore('cart', () => {
const lines = ref<CartLine[]>([])
const count = computed(() => lines.value.reduce((n, line) => n + line.qty, 0))
function add(product: { id: number, name: string, price: number }) {
const line = lines.value.find(l => l.id === product.id)
if (line) line.qty++
else lines.value.push({ ...product, qty: 1 })
}
return { lines, count, add }
})
The home page's Add buttons call cart.add(product) on the same store, so one click turns the badge to Cart (1) and puts the product on the cart page.
A store, like useState, starts again on a full page load, so a cart that must survive a reload belongs in a cookie or on the server.
What goes wrong
Both mistakes look fine while you click around alone, and break once the server renders a page. The first keeps the promo code in a ref at the top of a .ts file.
const promo = ref({ code: '', percent: 0 })
export function usePromo() {
return promo
}
Ana opens the newsletter link /?promo=SPRING10. Ben, another visitor, opens / a minute later, and the server renders her banner into his page. His browser loads the file fresh, with no promo code, so the console reports Hydration completed but contains mismatches. and the banner vanishes.
With a name or an email instead, View Source would show Ana's data to Ben until the server restarts. Create the state inside the function with useState:
export function usePromo() {
return useState('promo', () => ({ code: '', percent: 0 }))
}
Each request then starts from init, and Ben sees no banner.
The second mistake keeps a class instance in useState: a Cart class with an add method, in a file that is not in the Code tab.
class Cart {
lines: CartLine[] = []
add(line: CartLine) { this.lines.push(line) }
}
export const useCart = () => useState('cart', () => new Cart())
Reach a page that calls useCart() through a link and it works, because nothing is written into a payload. Reload it and the server answers 500, logging DevalueError: Cannot stringify arbitrary non-POJOs (a POJO is a plain old JavaScript object). Keep the data plain and the behaviour beside it, in a composable or a setup store:
export function useCart() {
const lines = useState<CartLine[]>('cart', () => [])
const add = (line: CartLine) => lines.value.push(line)
return { lines, add }
}
A plain array fits in the payload, so the reload renders the page.
What to remember
- A
refat the top of a.tsfile is shared by every visitor. Per-visitor state goes inuseStateor a Pinia store, created inside a function. useStatevalues travel in the payload, so they must be plain data: no class instances, no functions.clearNuxtState(key)leavesundefined. Pass{ reset: true }to get the starting value back.useStateand stores end at a full reload.useCookiekeeps a small choice across reloads and lets the server read it, for as long asmaxAgesays.- Use
useStatefor a value or two, and a Pinia store through@pinia/nuxtwhen state has actions, getters and several readers.
In your project
Do these in a real project. Each one says how to confirm it worked.- Replace
app/composables/usePromo.tswith the broken version from What goes wrong and save. Open/?promo=SPRING10, then/in a private window: View Source there contains SPRING10 and the console shows a hydration mismatch. Put theuseStateversion back and repeat: no promo code. - Open
/?promo=SPRING10, add a product, and go to/cart. Click Remove code: the line "Code SPRING10 applied" disappears. Delete{ reset: true }and repeat: the page fails, and the console showsCannot read properties of undefined (reading 'code'). - Choose EUR and reload: View Source shows euro prices, so the server knew. In DevTools, under Application and then Cookies,
currencyexpires a year from today. Delete themaxAgeline, choose EUR again, and Expires says Session.