Vue – State Management with Pinia

November 26, 20255 min readUpdated 8/24/2026

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 moment

Same 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.