
TypeScript First
Full type inference with autocompletion for state, getters, and actions
Vue Devtools
Time-travel debugging, state inspection, and action tracking built-in
Modular Design
Create multiple stores that can import and use each other
Pinia is built into Nuxt 3. Just start using it:
// stores/counter.ts
export const useCounterStore = defineStore("counter", {
state: () => ({ count: 0 }),
actions: {
increment() {
this.count++
},
},
})
For explicit installation:
npm install pinia @pinia/nuxt
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["@pinia/nuxt"],
})
npm install pinia
// main.ts
import { createApp } from "vue"
import { createPinia } from "pinia"
import App from "./App.vue"
const app = createApp(App)
app.use(createPinia())
app.mount("#app")
The familiar options syntax:
// stores/user.ts
import { defineStore } from "pinia"
export const useUserStore = defineStore("user", {
state: () => ({
name: "",
email: "",
isLoggedIn: false,
}),
getters: {
initials: (state) => {
return state.name
.split(" ")
.map((n) => n[0])
.join("")
},
displayName: (state) => {
return state.isLoggedIn ? state.name : "Guest"
},
},
actions: {
async login(email: string, password: string) {
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ email, password }),
})
const data = await response.json()
this.name = data.name
this.email = data.email
this.isLoggedIn = true
},
logout() {
this.name = ""
this.email = ""
this.isLoggedIn = false
},
},
})
Use ref(), computed(), and functions:
// stores/cart.ts
import { defineStore } from "pinia"
import { ref, computed } from "vue"
export const useCartStore = defineStore("cart", () => {
// State
const items = ref<CartItem[]>([])
// Getters
const totalItems = computed(() => items.value.reduce((sum, item) => sum + item.quantity, 0))
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const isEmpty = computed(() => items.value.length === 0)
// Actions
function addItem(product: Product) {
const existing = items.value.find((item) => item.id === product.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
function removeItem(productId: string) {
const index = items.value.findIndex((item) => item.id === productId)
if (index > -1) {
items.value.splice(index, 1)
}
}
function clearCart() {
items.value = []
}
return { items, totalItems, totalPrice, isEmpty, addItem, removeItem, clearCart }
})
<script setup>
const counterStore = useCounterStore()
</script>
<template>
<div>
<p>Count: {{ counterStore.count }}</p>
<button @click="counterStore.increment">Increment</button>
</div>
</template>
Use storeToRefs() to destructure while keeping reactivity:
<script setup>
import { storeToRefs } from "pinia"
const cartStore = useCartStore()
// Reactive state and getters
const { items, totalItems, totalPrice } = storeToRefs(cartStore)
// Actions can be destructured directly
const { addItem, removeItem } = cartStore
</script>
<template>
<div>
<p>{{ totalItems }} items - ${{ totalPrice.toFixed(2) }}</p>
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }} x{{ item.quantity }}
<button @click="removeItem(item.id)">Remove</button>
</li>
</ul>
</div>
</template>
<script setup>
const userStore = useUserStore()
// Direct mutation
function updateName(name: string) {
userStore.name = name
}
// Using $patch for multiple changes
function updateProfile(name: string, email: string) {
userStore.$patch({
name,
email,
})
}
// $patch with a function for complex updates
function addNotification(notification: Notification) {
userStore.$patch((state) => {
state.notifications.push(notification)
state.unreadCount++
})
}
// Reset to initial state
function resetUser() {
userStore.$reset()
}
</script>
Getters are computed properties for stores:
export const useProductStore = defineStore("products", {
state: () => ({
products: [] as Product[],
searchQuery: "",
selectedCategory: null as string | null,
}),
getters: {
// Simple getter
productCount: (state) => state.products.length,
// Getter using another getter
filteredProducts(): Product[] {
let result = this.products
if (this.selectedCategory) {
result = result.filter((p) => p.category === this.selectedCategory)
}
if (this.searchQuery) {
const query = this.searchQuery.toLowerCase()
result = result.filter((p) => p.name.toLowerCase().includes(query))
}
return result
},
// Getter that returns a function (for arguments)
getProductById: (state) => {
return (id: string) => state.products.find((p) => p.id === id)
},
// Getter accessing other stores
cartProductDetails(): ProductWithQuantity[] {
const cartStore = useCartStore()
return cartStore.items.map((item) => ({
...this.getProductById(item.id)!,
quantity: item.quantity,
}))
},
},
})
Handle API calls and async operations:
export const usePostStore = defineStore("posts", {
state: () => ({
posts: [] as Post[],
loading: false,
error: null as string | null,
}),
actions: {
async fetchPosts() {
this.loading = true
this.error = null
try {
const response = await fetch("/api/posts")
this.posts = await response.json()
} catch (e) {
this.error = "Failed to fetch posts"
} finally {
this.loading = false
}
},
async createPost(data: CreatePostData) {
const response = await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
const newPost = await response.json()
this.posts.push(newPost)
return newPost
},
async deletePost(id: string) {
await fetch(`/api/posts/${id}`, { method: "DELETE" })
const index = this.posts.findIndex((p) => p.id === id)
if (index > -1) {
this.posts.splice(index, 1)
}
},
},
})
Stores can use other stores:
// stores/checkout.ts
export const useCheckoutStore = defineStore("checkout", () => {
const cartStore = useCartStore()
const userStore = useUserStore()
const canCheckout = computed(() => !cartStore.isEmpty && userStore.isLoggedIn)
const orderSummary = computed(() => ({
items: cartStore.items,
subtotal: cartStore.totalPrice,
tax: cartStore.totalPrice * 0.1,
total: cartStore.totalPrice * 1.1,
shippingAddress: userStore.address,
}))
async function placeOrder() {
if (!canCheckout.value) {
throw new Error("Cannot checkout")
}
const response = await fetch("/api/orders", {
method: "POST",
body: JSON.stringify(orderSummary.value),
})
if (response.ok) {
cartStore.clearCart()
}
return response.json()
}
return { canCheckout, orderSummary, placeOrder }
})
React to store changes:
const cartStore = useCartStore()
// Subscribe to state changes
cartStore.$subscribe((mutation, state) => {
// Save to localStorage on every change
localStorage.setItem("cart", JSON.stringify(state.items))
})
// Subscribe to actions
cartStore.$onAction(({ name, args, after, onError }) => {
console.log(`Action ${name} called with`, args)
after((result) => {
console.log(`Action ${name} finished with`, result)
})
onError((error) => {
console.error(`Action ${name} failed with`, error)
})
})
Use the persist plugin to save state:
npm install pinia-plugin-persistedstate
// main.ts or plugins/pinia.ts
import piniaPluginPersistedstate from "pinia-plugin-persistedstate"
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// stores/settings.ts
export const useSettingsStore = defineStore(
"settings",
() => {
const theme = ref("light")
const language = ref("en")
return { theme, language }
},
{
persist: true, // Saves to localStorage automatically
}
)
import { setActivePinia, createPinia } from "pinia"
import { useCounterStore } from "./counter"
describe("Counter Store", () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it("increments count", () => {
const store = useCounterStore()
expect(store.count).toBe(0)
store.increment()
expect(store.count).toBe(1)
})
it("computes double count", () => {
const store = useCounterStore()
store.count = 5
expect(store.doubleCount).toBe(10)
})
})
Lightweight
Only ~1.5kb gzipped with no dependencies beyond Vue
Hot Module Replacement
Modify stores without reloading the page or losing state
SSR Support
Works seamlessly with server-side rendering in Nuxt