This episode covers Pinia, Vue's official state manager: defining stores with state, getters, and actions, Pinia plugins for extension, the difference between local state and global stores, plus best practices for data normalization and modular stores in large apps.

As an application grows, some state is needed in many distant components — for example, user data used in the header, the profile page, and the menu. Passing it through props becomes a mess; storing it in the root component is also fragile. The answer: global state management through a store.
Episode 10 covers Pinia, Vue's official state manager born from the lessons of Vuex. We'll dissect the store concept with state, getters, and actions, Pinia plugins for extending capabilities, the split between local state and global stores, and best practices for normalizing and modularizing stores in large applications.
Install Pinia and register it as a plugin:
npm install piniaimport { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
const app = createApp(App);
app.use(createPinia());
app.mount("#app");createPinia() creates the Pinia instance registered through app.use. After that, stores can be created and used in any component.
Pinia uses a pattern similar to composables. A store is defined with defineStore:
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export const useKeranjangStore = defineStore("keranjang", () => {
const items = ref([]);
const totalHarga = computed(() =>
items.value.reduce((total, item) => total + item.harga, 0)
);
function tambah(item) {
items.value.push(item);
}
return { items, totalHarga, tambah };
});defineStore("keranjang", ...) creates a store with the unique name keranjang. The setup store uses the Composition API: items as state, totalHarga as a getter, and tambah as an action.
State is reactive data owned globally by the store. Unlike local state, store state can be accessed by any component without prop drilling.
Getters compute values from state, equivalent to computed:
export const useKeranjangStore = defineStore("keranjang", () => {
const items = ref([]);
const jumlahItem = computed(() => items.value.length);
function itemById(id) {
return items.value.find((item) => item.id === id);
}
return { items, jumlahItem, itemById };
});jumlahItem is a pure getter, while itemById(id) is a parameterized getter — both use computed or plain functions to derive data.
Actions are where all state mutations and async logic live:
export const useKeranjangStore = defineStore("keranjang", () => {
const items = ref([]);
async function muatDariServer() {
const res = await fetch("/api/keranjang");
items.value = await res.json();
}
return { items, muatDariServer };
});muatDariServer() loads data from the API and updates the state. Routing all state changes through actions keeps the data flow traceable in one place.
<script setup>
import { useKeranjangStore } from "../stores/keranjang";
const store = useKeranjangStore();
</script>
<template>
<p>Total item: {{ store.jumlahItem }}</p>
</template>useKeranjangStore() gets the store instance; access state via store.xxx and call actions via store.tambah(...). Stores are reactive, so the UI updates along with them.
The most important rule in state management: not all state needs to be global.
ref.Storing too much in the store bloats it and makes it hard to test. Start with local state, and move things to a store only when they truly need to be shared.
When data is relational, store it in normalized form to avoid duplication:
export const useProdukStore = defineStore("produk", () => {
const byId = ref({});
const semuaId = ref([]);
function setProduk(list) {
byId.value = Object.fromEntries(list.map((p) => [p.id, p]));
semuaId.value = list.map((p) => p.id);
}
return { byId, semuaId, setProduk };
});byId maps ids to objects, semuaId holds the order. The map byId.value = Object.fromEntries(...) avoids data duplication — a technique you'll use for data that updates often.
Split stores by domain instead of one giant store:
src/stores/user.js for the user domainsrc/stores/keranjang.js for the cartsrc/stores/produk.js for productssrc/stores/notifikasi.js for notificationsEach store focuses on one domain and can use each other through useOtherStore().
Plugins extend all stores at once, for example for persistence:
import { piniaPluginPersist } from "./plugins/persist";
const pinia = createPinia();
pinia.use(piniaPluginPersist);pinia.use(plugin) registers a plugin that wraps the store lifecycle. This pattern is used for localStorage persistence, action logging, or multi-tab synchronization — we'll expand on it in episode 14.
Episode 10 taught you modern state management with Pinia: defining stores with the setup syntax using state, getters, and actions, plugins for extension, clear rules for when state is local and when it's global, plus normalization and modularization for large applications.
Key takeaways:
defineStore and the setup syntax.byId to avoid duplication.In the next episode 11, we'll cover forms and validation — form handling with v-model, validation patterns with VeeValidate or Vuelidate, dynamic forms with custom validation rules, plus form accessibility and user feedback.