Pinia is Vue's official state library. It replaced Vuex, it is much smaller than Vuex was, and if
you have read about mutations and commit — those are gone. A Pinia action
just changes the state.
Installing it
createApp(App).use(createPinia()).use(router).mount("#app");One plugin, and every component can now use every store.
A store
There are two ways to write one. Setup stores look exactly like
<script setup>:
export const useToastStore = defineStore("toast", () => {
const items = ref([]);
function push(message, variant = "success", ms = 3600) {
const id = nextId++;
items.value.push({ id, message, variant });
setTimeout(() => dismiss(id), ms);
return id;
}
const success = (m) => push(m, "success");
// Errors stay up longer - they usually carry something you need to read.
const error = (m) => push(m, "danger", 6000);
const info = (m) => push(m, "info");
function dismiss(id) {
items.value = items.value.filter((t) => t.id !== id);
}
return { items, push, success, error, info, dismiss };
});Refs are state, computeds are getters, functions are actions, and the returned object is the store's public surface. There is nothing new to learn — it is the Composition API pointed at a singleton.
The alternative is an option store, which some people prefer for its structure:
export const useToastStore = defineStore("toast", {
state: () => ({ items: [] }),
getters: {
count: (state) => state.items.length,
},
actions: {
dismiss(id) {
this.items = this.items.filter((t) => t.id !== id);
},
},
});Both are fully supported. This track uses setup stores because they need no new syntax, compose
with other composables, and avoid this entirely.
The store id
defineStore("toast", ...) — the first argument is a unique id. It is what devtools
labels the store, and what any persistence plugin keys off. It must be unique across the app.
Using a store
const toast = useToastStore();
toast.success("Changes saved.");
toast.error(e.message);useToastStore() returns the same instance every time. Call it in ten components and
there is one store.
Note it is a useX function and follows the same rule as any composable: call it at the
top level of setup, not inside a callback. There is a reason beyond convention — Pinia needs an active
instance to resolve, which does not exist before app.use(createPinia()). That is also why
a store must not be called at module scope, in a file that is imported before the app is created.
storeToRefs, and the bug it prevents
The store object is reactive. Destructuring it is not:
const toast = useToastStore();
const { items } = toast; // WRONG -- a plain array, frozen at this momentSame failure as destructuring a reactive in lesson 5. It renders once, correctly, and
then never updates — which makes it look like the store is broken rather than the destructure.
storeToRefs converts state and getters into refs that keep the connection:
const toast = useToastStore();
const { items } = storeToRefs(toast);Note what it does not do: actions are plain functions and are not converted, so you take
them off the store directly. That is why the toast component keeps both — items through
storeToRefs, and toast.dismiss called on the store in the template.
A store that is not just state
The auth store is the more interesting one, because it shows what a store is for beyond holding values:
const token = ref(readToken());
const user = ref(readUser());
const loading = ref(false);
const error = ref(null);State is seeded from storage, so a refresh does not sign you out. The comment above it in the source is the important part: the token is the only thing the server trusts, and the cached user exists purely so the sidebar can render a name before any request completes.
Derived state is computed, as in lesson 6, and business rules live with the state they concern:
function canEdit(reel) {
if (isAdmin.value) return true;
if (!isCreator.value) return false;
return reel?.creator?.id === user.value?.creatorId;
}That rule — a creator may only touch their own reels — is enforced by the server too. Having it in the store as well is not duplication for its own sake; it is what lets the UI avoid offering buttons that are going to come back 403.
An action wraps the async work and owns the loading and error state that goes with it:
async function login(email, password) {
loading.value = true;
error.value = null;
try {
const res = await api.login(email, password);
token.value = res.token;
user.value = res.user;
writeSession(res.token, res.user);
return res.user;
} catch (e) {loading and error are set and cleared here rather than in the login
component, so every caller gets them right. The finally is what makes a failed login
leave the button usable.
Reacting to something outside Vue
One more piece from the same store, worth studying because the problem is common:
window.addEventListener(SESSION_EXPIRED, () => {
token.value = null;
user.value = null;
});The HTTP client discovers an expired session on a 401. It cannot import the auth store to clear it — the store imports the client, so that would be a circular import. It dispatches an event instead and the store listens.
Decoupling through an event is a normal answer to a circular dependency, and this is a clean example of it: one direction of the cycle becomes a message rather than an import.
Resetting, patching, subscribing
// Several changes at once, as one devtools entry.
store.$patch({ loading: false, error: null });
// Back to the initial state. Option stores get this free; a setup store needs
// its own reset action, because Pinia cannot know what "initial" was.
store.$reset();
// Every mutation -- how persistence plugins are written.
store.$subscribe((mutation, state) => {
localStorage.setItem("filters", JSON.stringify(state));
});When you do not need a store
Most state is not application state. A form's fields belong to the form. A modal's open flag belongs to the component that owns the modal. Putting them in a store makes them global, which means anything can change them and you have lost the ability to reason about the component on its own.
The demo application has 36 components and two stores: the signed-in user, and the toast queue. Both are genuinely application-wide. Everything else is local state or props, which is the right ratio.
Next: The Options API — the other way to write all of this, and why this track does not.