● David R. Longnecker
Chapter 8 · Frontend

Centralized State

Provide/inject solves passing one or two values down a tree. But most real apps have dozens of pieces of shared data — todos, the logged-in user, connection status, live counts — read and written from components scattered all over the tree. At that scale you want one obvious place all of it lives, and one obvious set of ways to change it. That's a store.

Why apps outgrow props #

Imagine a todo app that started with everything in one component: a ref for the list, a function to add items. It works, until you split the UI into a header showing a count, a list of items, a form to add new ones, and a footer with a "clear completed" button. Every one of those needs the same todos data. You could provide/inject it, but you'd also need to agree, informally, on which component is allowed to mutate it and how — nothing stops two different components from reaching in and changing the array in two different, inconsistent ways.

A store fixes this by drawing a hard line: state lives in exactly one place, and the only way to change it is through a small, named set of functions the store itself defines. Every component that reads from the store sees the same data; nothing mutates it from outside. This is the single source of truth principle applied to your UI.

State, getters, and actions #

The examples below use Pinia's shape, the standard state-management library for Vue — see pinia.vuejs.org. A Pinia store has three parts:

Two ways to write the same store

Pinia's own docs lead with the options store syntax: an object literal with state, getters, and actions keys, mirroring the vocabulary above almost exactly. The example below uses the setup store syntax instead — a plain function body using ref() and computed(), returned as an object — because it's the same shape as the composables from Chapter 7. Both compile to the same thing; setup stores just let you reuse everything you already know about ref and computed instead of learning a second, parallel syntax.

stores/todos.ts
import { defineStore } from "pinia";
import { ref, computed } from "vue";

export const useTodoStore = defineStore("todos", () => {
  // state
  const todos = ref<{ id: number; title: string; done: boolean }[]>([]);

  // getters
  const completedCount = computed(
    () => todos.value.filter((t) => t.done).length
  );
  const remaining = computed(
    () => todos.value.length - completedCount.value
  );

  // actions
  async function fetchTodos() {
    const res = await fetch("/api/todos");
    todos.value = await res.json();
  }

  async function addTodo(title: string) {
    const res = await fetch("/api/todos", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title }),
    });
    const created = await res.json();
    todos.value.push(created);
  }

  function markDone(id: number) {
    const todo = todos.value.find((t) => t.id === id);
    if (todo) todo.done = true;
  }

  // called when the server pushes a todo this client didn't create itself
  function applyRemoteTodo(todo: { id: number; title: string; done: boolean }) {
    const exists = todos.value.some((t) => t.id === todo.id);
    if (!exists) todos.value.push(todo);
  }

  return {
    todos, completedCount, remaining,
    fetchTodos, addTodo, markDone, applyRemoteTodo,
  };
});

Any component calls const store = useTodoStore() and gets the exact same reactive state every other component using the store also sees — not a copy, the same object. Compare this to a composable like useTodos() from the last chapter, which hands each caller its own independent state: a store is what you reach for specifically when the state needs to be shared and singular across the whole app.

That "exact same object, everywhere" behavior isn't magic — Pinia is built on the same provide/inject mechanism from Chapter 7. Installing Pinia provides a central registry at the app root; useTodoStore() is a thin wrapper that injects from it and creates the store on first use. You're not learning a new dependency-injection system here, just a naming convention layered on top of the one you already know.

main.ts — wiring Pinia up once, at the app root
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";

const app = createApp(App);
app.use(createPinia());
app.mount("#app");

Without this, useTodoStore() throws — there's no registry for it to attach to. Every Vue project scaffolded with Pinia includes this by default; it's worth knowing what it does, not just that it's there.

using the store in a component
<script setup>
import { onMounted } from "vue";
import { useTodoStore } from "./stores/todos";

const store = useTodoStore();
onMounted(() => store.fetchTodos());
</script>

<template>
  <p>{{ store.remaining }} left, {{ store.completedCount }} done</p>
  <ul>
    <li v-for="todo in store.todos" :key="todo.id">
      <span>{{ todo.title }}</span>
      <button @click="store.markDone(todo.id)">Done</button>
    </li>
  </ul>
</template>
Key idea

Components never mutate store.todos directly — they call store.addTodo() or store.markDone(). The distinction matters: if every mutation goes through a named action, you can find every place your data changes by searching for the action's name, instead of grepping for every component that happens to touch the array.

How a change propagates #

The flow is the same regardless of which component triggers it: call an action, the action mutates state, and every component reading that state (directly, or through a getter) re-renders automatically — the same reactivity mechanism from Chapter 6, just centered on a store instead of a single component's local refs.

A component calls a store action, the action mutates state, and every subscribed component re-renders AddTodoForm calls addTodo() Store action state getters TodoList HeaderCount Footer
One action mutates one piece of state; every component reading it — regardless of where it sits in the tree — re-renders on its own.

Actions as the landing point for server events #

So far every action in this chapter has been triggered by a user clicking something. But the same pattern handles updates that originate on the server. If another user (or another browser tab) adds a todo, and the server pushes a live event down to this client — the mechanism for that is covered in Chapter 11 — the event handler's job is simply to call a store action, the same as a button click would have:

applying a server-pushed event (the EventSource API is covered in Chapter 11)
// somewhere that owns the live connection
const events = new EventSource("/api/live");
const store = useTodoStore();

events.addEventListener("todo-created", (event) => {
  store.applyRemoteTodo(JSON.parse(event.data));
});

Because every component reads from the store rather than local state, this requires no special handling on the component side at all — the list re-renders exactly as it would if the local user had clicked "add." Centralizing state isn't just about tidiness; it's what makes "the server can also change things" a small addition instead of a redesign.

Don't reach into the store from outside

It's tempting to skip the action and just write store.todos.push(...) directly in the event handler above — it would work. But that's exactly the rule this chapter opened with: state changes only through named actions, never direct mutation from outside. applyRemoteTodo also does one thing a raw push can't: it checks whether the todo already exists before adding it, which matters the moment a client receives an event for something it created itself (see the capstone in Chapter 17).

Tip

A useful discipline: keep actions as the only place that touches fetch() or a live connection. Components ask the store for data and call actions; they never make network calls themselves. That boundary makes it obvious, later, where to add loading states, retries, or error handling.

Try it

Add a removeTodo(id) action to the store above, plus a getter hasCompleted that's true when completedCount > 0. Wire a "clear completed" button in a footer component to an action that filters todos.value down to the incomplete ones — notice you never have to tell HeaderCount or TodoList anything happened.