Skip to the solution
HardFrontend11 min read

How would you structure state management for a large Vue app?

690 views

The problem

Question presented to candidate: "You are joining a team with a 300-component Vue app. Everything lives in a handful of giant Pinia stores, half the bugs are stale data, and SSR is coming next quarter. How would you structure state management going forward?"

What a strong answer should cover:

  • Classify state first: local UI state, subtree state, URL state, server state (cached copies of backend data), and true global client state. Each has a different best home.
  • Keep state as local as possible: component refs, composables, provide/inject for a subtree; the URL query for shareable view state.
  • Treat server state separately: a query cache (Pinia Colada, TanStack Vue Query or a small in-house layer) handles dedupe, caching, invalidation and loading/error state better than hand-written store actions.
  • Pinia for global client state, split into small domain stores by feature, colocated with the feature; stores may use other stores inside actions/getters, with no cycles at setup time.
  • SSR safety: no module-level mutable singletons; one Pinia (and one cache) per request; serialise state for hydration.
  • Conventions that scale: store ids by feature, actions as the only writers for shared stores, storeToRefs for destructuring, tests per store.

Clarifying questions expected:

  • "How much of the state is server data versus purely client-side?"
  • "Which rendering mode is coming: SSR with hydration, or static generation?"
  • "Do several tabs or users need to see the same data live?"

Code / implementation expected: Optional β€” a domain store depending on an app store, and a sketch of the layer diagram.

  • vue
  • state-management
  • pinia
  • architecture

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

11 min read Β· 11 sections

Read the code

VueServer state vs client state: a tiny shared query cache (fake API)
<script setup>
// Server state is not client state. This tiny query cache (what libraries
// such as Pinia Colada or TanStack Query do properly) shares one request
// between every component that asks for the same key. The API is FAKE:
// a local promise with a delay, so the demo needs no network.
import { shallowRef, ref, defineComponent, h } from "vue";

let networkCalls = 0;
const fakeApi = (key) =>
  new Promise((resolve) => {
    networkCalls++;
    setTimeout(() => resolve({ key, fetchedAt: networkCalls }), 100);
  });

const cache = new Map(); // key -> { data, error, loading, promise }
function useQuery(key) {
  if (!cache.has(key)) {
    const entry = { data: shallowRef(null), error: shallowRef(null), loading: ref(true), promise: null };
    entry.promise = fakeApi(key)
      .then((d) => (entry.data.value = d))
      .catch((e) => (entry.error.value = e))
      .finally(() => (entry.loading.value = false));
    cache.set(key, entry);
  }
  return cache.get(key); // same refs for every caller
}
function invalidate(key) {
  cache.delete(key);
}

const UserBadge = defineComponent({
  props: { where: String },
  setup(props) {
    const { data, loading } = useQuery("user/1");
    return () => h("p", `${props.where}: ${loading.value ? "loading..." : `user/1 (response #${data.value.fetchedAt})`}`);
  },
});

const show = ref(true);
const calls = ref(0);
const refresh = () => setTimeout(() => (calls.value = networkCalls), 150);
refresh();
function refetch() {
  invalidate("user/1");
  show.value = false;
  setTimeout(() => { show.value = true; refresh(); });
}
</script>

<template>
  <template v-if="show">
    <UserBadge where="Header" />
    <UserBadge where="Sidebar" />
    <UserBadge where="Profile page" />
  </template>
  <button @click="refetch">Invalidate and refetch</button>
  <p>network calls so far: {{ calls }}</p>
</template>
An app store and a domain store that depends on it
// stores/auth.ts: a small, app-wide store (who is signed in)
import { defineStore, acceptHMRUpdate } from "pinia";
import { ref, computed } from "vue";

export const useAuthStore = defineStore("auth", () => {
  const user = ref<{ id: number; name: string; plan: "free" | "pro" } | null>(null);
  const isPro = computed(() => user.value?.plan === "pro");
  function signIn(u: NonNullable<typeof user.value>) { user.value = u; }
  function signOut() { user.value = null; }
  return { user, isPro, signIn, signOut };
});

// features/cart/cart.store.ts: a domain store that DEPENDS on auth.
// Call the other store inside the setup function, never at module top level.
export const useCartStore = defineStore("cart", () => {
  const auth = useAuthStore();
  const lines = ref<{ sku: string; qty: number; price: number }[]>([]);
  const subtotal = computed(() => lines.value.reduce((s, l) => s + l.qty * l.price, 0));
  const discount = computed(() => (auth.isPro ? Math.round(subtotal.value * 0.1) : 0));
  const total = computed(() => subtotal.value - discount.value);

  function add(sku: string, price: number) {
    const line = lines.value.find((l) => l.sku === sku);
    line ? line.qty++ : lines.value.push({ sku, qty: 1, price });
  }
  // Cross-store reaction kept in ONE place instead of in random components
  auth.$onAction(({ name, after }) => {
    if (name === "signOut") after(() => (lines.value = []));
  });
  return { lines, subtotal, discount, total, add };
});

if (import.meta.hot) import.meta.hot.accept(acceptHMRUpdate(useCartStore, import.meta.hot));

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 83 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextHard

What are shallowRef and shallowReactive?

Continue