As an app grows, two questions matter more and more: where state should live, and how server data gets loaded. This chapter covers reusing logic with composables, managing global state with Pinia, and exchanging data through Nuxt's useFetch, useAsyncData and server routes.
Choosing where state lives
Rather than putting everything in a global store from day one, keep state as close as possible to where it is used and lift it up only when sharing becomes necessary.
Reusing logic with composables
A composable is a function, named with a use prefix, that bundles reactive state and logic and returns them. Reach for one when several components need the same behavior.
// composables/useCounter.ts
import { ref, computed } from "vue";
export function useCounter(initial = 0) {
const count = ref(initial);
const double = computed(() => count.value * 2);
const increment = () => count.value++;
const reset = () => (count.value = initial);
return { count, double, increment, reset };
}
In a component, write const { count, increment } = useCounter(10);. Every call creates independent state. In Nuxt, functions in the composables/ folder are auto-imported.
Global state with Pinia
Pinia is the official state management library for Vue. Stores are defined with defineStore, and the setup syntax looks just like a composable: ref becomes state, computed becomes a getter, and functions become actions.
// stores/cart.ts
import { defineStore } from "pinia";
import { ref, computed } from "vue";
type Item = { id: number; name: string; price: number; qty: number };
export const useCartStore = defineStore("cart", () => {
const items = ref<Item[]>([]);
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0));
function add(item: Omit<Item, "qty">) {
const found = items.value.find((i) => i.id === item.id);
if (found) found.qty++;
else items.value.push({ ...item, qty: 1 });
}
return { items, total, add };
});
Destructuring state straight off a store breaks reactivity, so use storeToRefs. Actions can be destructured as they are.
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useCartStore } from "@/stores/cart";
const cart = useCartStore();
const { items, total } = storeToRefs(cart);
</script>
<template>
<p>{{ items.length }} items, total {{ total }}</p>
<button @click="cart.add({ id: 1, name: 'Book', price: 15 })">Add to cart</button>
</template>
In Nuxt, add the @pinia/nuxt module with npx nuxi module add pinia; state created during server rendering is then transferred safely to the client.
Nuxt server routes
Files in server/api/ become API endpoints. Adding .get or .post before the extension restricts the HTTP method.
// server/api/todos.get.ts
export default defineEventHandler(async (event) => {
const { done } = getQuery(event);
const todos = [
{ id: 1, text: "Learn Vue", done: true },
{ id: 2, text: "Deploy Nuxt", done: false },
];
return done === undefined ? todos : todos.filter((t) => String(t.done) === done);
});
// server/api/todos.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody<{ text: string }>(event);
if (!body?.text) {
throw createError({ statusCode: 400, statusMessage: "text is required" });
}
return { id: Date.now(), text: body.text, done: false };
});
Returned objects are serialized as JSON. In a real service, this is where you call a database or an external API, reading secrets with useRuntimeConfig(event).
useFetch and useAsyncData
Use useFetch to load data for a page. During server rendering the result is fetched once and shipped with the page, so the client does not repeat the request.
<script setup lang="ts">
const { data: todos, status, error, refresh } = await useFetch("/api/todos");
async function addTodo(text: string) {
await $fetch("/api/todos", { method: "POST", body: { text } });
await refresh();
}
</script>
<template>
<p v-if="status === 'pending'">Loading...</p>
<p v-else-if="error">Error: {{ error.message }}</p>
<ul v-else>
<li v-for="todo in todos" :key="todo.id">{{ todo.text }}</li>
</ul>
</template>
useFetch is shorthand for useAsyncData(key, () => $fetch(url)). Call useAsyncData directly when you combine several requests or call something other than $fetch, such as a database SDK. For requests triggered by user actions, like a button click, use $fetch. For simple state shared between server and client, useState("key", () => initialValue) creates an SSR-safe ref.
Summary
- Keep state close to where it is used; move it into a composable or Pinia store when it needs to be shared.
- Use
storeToRefs when pulling state out of a Pinia store. - Files in Nuxt's
server/api/ create endpoints with defineEventHandler. - Load page data with
useFetch or useAsyncData, and send user-triggered requests with $fetch.
0 comments
Sign in · Sign in to leave a comment.