DEV Community

Cover image for How to Manage Complex State in Large Vue Projects: "Scattered" vs "Systematic"
Uncle Pushui
Uncle Pushui

Posted on

How to Manage Complex State in Large Vue Projects: "Scattered" vs "Systematic"

Large frontend systems rarely hit their limit because they chose the wrong individual state tool. They hit it when ownership, sharing boundaries, cache semantics, persistence, and SSR rules no longer compose into one coherent system.

The problem is not tool shortage. It is boundary drift.

If I had to compress the entire argument into one sentence, it would be this:

Large Vue projects usually do not become difficult because they need one more library. They become difficult because state mechanisms keep multiplying while state boundaries keep losing clarity.

That is the real subject of this article.

Not whether Pinia is sufficient.
Not whether composables are overused.
Not whether Vue itself is somehow inadequate.

The real problem is more structural than that.

In a large codebase, the usual mechanisms all solve legitimate local problems:

  • local reactive state,
  • composables,
  • provide/inject,
  • stores,
  • query cache,
  • local persistence,
  • SSR hydration rules.

Any one of those choices can be correct.

And still, taken together, they can produce a state layer that feels progressively less stable.

Not because one piece is obviously wrong, but because the whole system gradually loses shape.

That is what I mean by the scattered version.

Not a broken system. Not an amateur system. Not even an irrational one.

A scattered system is one where:

  • each local decision can be justified,
  • each mechanism solves a real problem,
  • but the overall state architecture becomes harder and harder to explain, modify, and trust.

What Zova/Cabloy tries to offer is not a replacement for Vue, but a more systematic way to organize state on top of Vue:

  • Bean / Controller defines ownership,
  • IoC Scope defines how far state is shared,
  • Model defines a unified state layer,
  • Resource Owner defines the boundary for resource-level semantics such as queries, schema, permissions, and invalidation.

If the project is small, short-lived, or handled by a very small team, the scattered version may be perfectly adequate.

But once the system becomes long-lived, multi-person, SSR-aware, cache-heavy, and persistence-heavy, the difference between scattered and systematic stops being a matter of taste.

It becomes a question of architecture.

And that leads to the real question underneath the tooling discussion:

Should complex state continue to be held together mainly by team discipline, or should more of it be held together by structure?


The decisive questions sit upstream of tooling

Most state-management discussions begin at the tool layer:

  • Should we use Pinia?
  • Should we add Query?
  • How should we persist local state?
  • How should we handle SSR hydration?

Those are sensible questions.

But in large systems, they are not the first questions.

The first questions are these:

  • Ownership — which object actually owns this state?
  • Sharing boundary — is it local, parent/child shared, app-level, or system-level?
  • State family — is it server state, in-memory state, local persistence, cookie-backed state, or async persistent state?
  • Cache semantics — who owns the key, invalidation, and restore rules?
  • Lifecycle — what survives disposal, refresh, route transitions, and SSR request boundaries?
  • Resource semantics — is this just a value, or part of a larger resource boundary that also owns schema, permissions, queries, forms, and actions?

When those questions are answered by different local mechanisms, the result is usually familiar:

  • some state lives in pages,
  • some lives in composables,
  • some lives in stores,
  • some lives in query cache,
  • some lives in persistence wrappers.

The application still works.

But the architecture becomes progressively harder to narrate.

And once a system becomes hard to narrate, it usually becomes hard to evolve safely.

That is the real failure mode of state management in large projects.

Not that a feature cannot be built.

But that the team gradually loses a clean answer to the most important question:

Why does this state live here, why is this mechanism responsible for it, and where exactly does its boundary end?

Large systems usually do not need more state tools.

They need a stronger organizational model for the tools they already have.


Why large Vue projects drift into the scattered version so easily

Because the road into it is the most natural road available.

A team starts with local state.
Then extracts a composable.
Then uses provide/inject.
Then adds a store.
Then adds Query.
Then adds local persistence.
Then patches SSR behavior.

Every move is locally rational.

That is exactly why the outcome is so common.

The issue is not that teams are making obviously bad decisions.

The issue is that the default growth path is a local-solution path, not a boundary-first path.

And over time, that difference compounds.

The same symptoms begin to appear again and again:

  • the same kind of state lands in different places on different screens,
  • refresh rules are hidden inside mutation success handlers,
  • persistence is treated as an afterthought rather than part of state semantics,
  • "global state" quietly mixes request-level and long-lived system concerns,
  • one resource's schema, permissions, forms, queries, and invalidation rules end up split across several layers.

This is the critical distinction:

The scattered version is not wrong because the tools are wrong. It is wrong because each tool solves one slice while the system never grows one stable ownership model.

That creates a very particular form of technical debt.

Not sudden failure.

Not dramatic breakage.

A slower, more corrosive debt: the project becomes increasingly dependent on memory, habit, and tribal knowledge to remain coherent.

At first, that feels like flexibility.

Later, it feels like fog.


What Zova/Cabloy changes is not the reactive core, but the authoring model

The most important thing to understand about Zova/Cabloy is this:

Vue 3 remains the reactive foundation. What changes is the programming model the business layer encounters first.

In mainstream Vue development, the most visible concepts are usually:

  • setup(),
  • ref() / reactive(),
  • computed(),
  • composables,
  • provide/inject,
  • stores.

In Zova, the center of gravity shifts.

The foreground concepts become:

  • Controller / Bean instances as visible state owners,
  • __init__ as a lifecycle-aware wiring point,
  • IoC scopes as the sharing model,
  • Model as the unified state layer,
  • Resource Owner as the owner of resource-level frontend semantics.

That changes the first design question.

Instead of asking:

  • should this go into a store?

You are pushed to ask:

  • which bean owns this?
  • which scope should share it?
  • which model family should represent it?

That is not a cosmetic shift.

It changes where architectural decisions happen.

In the scattered version, state tends to appear first and only later search for a home.

In the systematic version, the home is chosen first, and implementation follows.

That sounds subtle.

In a large system, it is not subtle at all.

Because the hardest thing in a growing codebase is not shipping one more feature.

It is continuing to place boundaries correctly as the system gets more complex.


Layer 1: make ownership explicit

In many Vue projects, the most natural host for state is a local declaration inside setup():

const count = ref(0);
const countLabel = computed(() => `=== ${count.value} ===`);
Enter fullscreen mode Exit fullscreen mode

There is nothing wrong with that pattern.

But at scale, it often produces a predictable drift: state appears as a local variable first, then gradually leaks outward into composables, stores, parent components, and other abstractions.

Zova begins from a different assumption. Visible state typically lives directly on a Controller or Bean instance:

count: number = 0;

protected async __init__() {
  this.countLabel = this.$computed(() => {
    return `=== ${this.count} ===`;
  });
}

increment() {
  this.count++;
}
Enter fullscreen mode Exit fullscreen mode

The important point is not that you wrote less .value.

The important point is this:

  • the state host becomes a framework-managed instance,
  • derived state becomes lifecycle-aware wiring on that instance,
  • behavior and state collapse back under one explicit owner.

That changes how a large codebase is read.

In ordinary Vue code, the reader often asks:

  • which composable owns this?

In Zova, the reader is more likely to ask:

  • which Bean or Controller owns this?

That is a healthier question in a large system.

Because large systems rarely suffer from a lack of flexibility.

They suffer from a lack of stable ownership.

And once ownership drifts, scope, persistence, cache identity, SSR behavior, and invalidation tend to drift with it.


Layer 2: make sharing a scope problem instead of a mechanism-switching problem

Large Vue projects also become scattered because sharing is expressed through multiple unrelated mechanisms.

The word "shared" can point to very different strategies:

  • local component sharing through local state or composables,
  • parent-child sharing through props/emits or provide/inject,
  • app-level sharing through a store,
  • long-lived sharing through module singletons, plugin state, or some implicit global object.

So the system quietly learns a rule like this:

When the sharing boundary changes, the mechanism changes too.

Zova reframes that into one IoC model with multiple scopes:

Sharing range Common Vue 3 pattern Zova question framing
component-internal local state, component-local composables should this be a ctx-scoped Bean?
between-components props/emits, parent-owned composables, provide/inject should this use hierarchical injection such as host / skipSelf?
app-global stores, app-level provided state should this be an app-scoped shared Bean?
system-level module singletons, long-lived imported state should this be a sys-scoped state that outlives requests?

The value is not just cleaner terminology.

It is a different way of framing the problem:

Do not start with the sharing mechanism. Start with the sharing boundary.

A compact mental model looks like this:

class ControllerPage {
  @Use()
  $$localCounterState: CounterState;

  @Use({ injectionScope: 'host' })
  $$hostCounterState: CounterState;

  @Use({ injectionScope: 'app' })
  $$appCounterState: CounterState;

  @Use({ injectionScope: 'sys' })
  $$sysCounterState: CounterState;
}
Enter fullscreen mode Exit fullscreen mode

The strength of this model is not only uniform syntax.

It is also that:

  • the sharing boundary becomes explicit,
  • state does not need to jump between unrelated mechanisms as sharing expands,
  • app-level and system-level state stop being casually mixed together.

This matters even more in SSR.

Because many teams casually label both of the following things as "global state":

  • request-level or app-level state — state that should follow an app instance or one SSR request,
  • system-level state — state that should outlive those requests.

If that distinction is not built into the model, SSR bugs become easy to create and hard to reason about.

Zova moves the difference directly into the architecture through app versus sys.

That is what a systematic model does well:

It turns boundaries that used to live in team memory into explicit structural rules.


Layer 3: Model is not just a request layer, and not just another store

Even teams that use both a store and a query library often unify only part of the state problem.

The usual split is familiar:

  • stores handle client-shared state,
  • query handles server-state cache,
  • localStorage utilities handle persistence,
  • cookie utilities handle request-related state,
  • async persistence is wrapped somewhere else.

That is still a scattered model.

Every state family has a tool.

But the state families do not share one coherent owner.

Zova's Model pushes the abstraction one step further.

It does not only represent remote data. It brings multiple state families under one model-state layer:

  • data — server / query-style state,
  • mem — in-memory state,
  • local — localStorage-backed state,
  • cookie — cookie-backed state,
  • db — async persistent state.

That changes the conversation in an important way.

Server state, local persistence, and in-memory state no longer need to be treated as unrelated worlds.

They still have different semantics, but they can live under one unified model ownership.

And this is not merely a thin wrapper over other tools.

Zova's Model is built on TanStack Query, but what the business layer sees is a more structured interface:

  • query keys carry model identity,
  • mutation keys carry model identity,
  • invalidation policy can belong to the model,
  • local / cookie / db state still participates in model-owned keying and restore semantics,
  • SSR and hydration behavior can follow model rules.

So the accurate description is not "Model is another store."

It is this:

Model is a unified state boundary for query, mutation, persistence, invalidation, restore behavior, and SSR semantics.

That is also why localStorage should not be understood as merely "saving something somewhere."

A more precise description is:

local-storage state is not a separate store; it is model-owned query state with a local-storage persister.

That may sound like a wording change, but it affects the whole architecture:

  • which model owns the state,
  • how its key space is isolated,
  • how restore works,
  • who owns invalidation,
  • whether it participates in SSR dehydration and hydration behavior.

In the scattered version, those answers are spread across utilities and conventions.

In the systematic version, they are pulled back into one model boundary.

That is the kind of abstraction large systems actually need.


Layer 4: pull resource semantics back under a Resource Owner

Even teams with both a store and a query layer often continue to fragment at the resource boundary.

Take a typical list / form / detail resource.

The frontend still needs to know all of this:

  • how the API path is built,
  • how the list is fetched,
  • how one row is fetched,
  • how create / update / delete work,
  • where schema comes from,
  • how permissions are checked,
  • which caches must be invalidated after success,
  • how the form layer is wired.

At that point, the problem is no longer "a piece of state."

It is a bundle of resource semantics.

This is where ModelResource becomes especially important:

It allows a model to become not just a query wrapper, but the frontend owner of a resource boundary.

That means the model can own:

  • resource bootstrap,
  • schema access,
  • permission access,
  • form integration,
  • query state,
  • mutation state,
  • cache invalidation policy.

A large amount of logic that would otherwise be scattered across pages, forms, buttons, request wrappers, schema helpers, and permission helpers gets pulled back into one stable boundary.

The shortest way to say it is this:

The page consumes resource semantics. The model owns query semantics.

That matters because it lifts the discussion from "where should this value live?" to "who owns this entire resource-shaped complexity?"

And once that resource boundary is stable, pages, forms, permissions, schema, and cache behavior are much more likely to remain stable together.

That is one of the most valuable properties a large codebase can have:

Not just reusable logic, but reusable boundaries.


One table to summarize the difference

Dimension Scattered version Systematic version
State ownership whoever touches it first tends to host it ownership is assigned first to Bean / Controller / Model
Sharing model composables, provide/inject, stores grow in parallel sharing is discussed through IoC scopes
Server data owned by a separate request or query layer part of Model's unified state boundary
Local persistence localStorage / cookie wrappers remain separate utilities local / cookie / db live inside model-state families
Cache keys often become team-defined strings namespaced by model identity and selectors
Invalidation handled by pages, buttons, and mutation callbacks owned as model policy
SSR semantics usually patched through conventions structured through app / sys and hydration rules
Reading strategy find hooks and variables, then infer boundaries inspect owner, scope, and model first
Complexity control mostly maintained by team discipline more of it is maintained by structure

The judgment behind the table is simple:

The scattered version can work, but it manages complexity mainly through discipline. The systematic version moves more of that burden into structure.

Or, more bluntly:

  • the scattered version is easier to start,
  • the systematic version is easier to scale.

And large projects are rarely decided by how elegant they felt in week one.

They are decided by whether the architecture is still legible six months later.


Three practical scenarios

1. Page filter state

In the scattered version, page-level filter state often evolves like this:

  • it starts as a few local refs,
  • then moves into a composable,
  • then gets saved to localStorage,
  • then later moves into a store because another page needs it too.

That path can work.

But after enough steps, the crucial questions become harder to answer:

  • which page actually owns it?
  • is it page-local, resource-level, or app-level?
  • is persistence just a UX convenience, or part of the state semantics?

In the systematic version, the first question is not "should we extract a composable?"

It is:

  • is this page / controller-local state?
  • should it instead be mem or local state on a Model?
  • does it need model identity in its key space?

Answer those questions early, and the state tends to keep a much more stable home.

2. List query and refresh after create

In the scattered version, refreshing a list after creating a record often means:

  • manually refetching the list,
  • invalidating queries from the page,
  • or letting some store action do it as a side effect.

It works.

But the refresh rule ends up distributed across multiple call sites.

In the systematic version, this belongs much more naturally to Model ownership:

  • the list query belongs to the model,
  • the create mutation belongs to the model,
  • the invalidation policy after create belongs to the model too.

The page consumes a resource action instead of coordinating cache semantics itself.

3. Resource forms, permissions, and schema

The scattered version usually loses control not at simple fetching, but at the semantics that accumulate around a resource:

  • which fields are editable,
  • which buttons are visible,
  • where the schema comes from,
  • which regions refresh after which action.

If those rules are split across pages, forms, buttons, helper utilities, store logic, and query logic, the codebase becomes only locally understandable.

The Resource Owner mindset is different:

  • pull that semantic bundle back to one stable owner,
  • let the page consume it,
  • let the model own it.

That is what effective complex-state management actually looks like.

Across all three scenarios, the same distinction appears again:

The scattered version solves the immediate problem first. The systematic version places the state inside the right boundary first.


The systematic version is not free

This part matters.

A more systematic architecture is not a free lunch.

Its cost includes at least this:

  • you accept stronger architectural constraints,
  • you need to learn roles like owner, scope, and model,
  • you cannot keep treating every piece of state as a local detail that will be sorted out later,
  • for small projects, short projects, or one-off pages, the structure may feel heavier than necessary.

So the conclusion is definitely not:

  • every Vue project should be written this way.

The more accurate conclusion is:

When complexity is still low, the scattered version feels flexible. When complexity keeps rising, the systematic version becomes far more stable.

What you save early is abstraction cost.

What you usually pay later is boundary cost.

That is the real trade-off.

The systematic version does not guarantee that day one feels fastest.

It gives you a much better chance that day 180 still feels manageable.


Final judgment: the ceiling is architectural, not just tool-level

If state management is reduced to "which library should we choose," the conversation remains too shallow.

In large frontend systems, the more decisive questions are usually these:

  • does state have a stable owner?
  • are sharing boundaries explicit?
  • is cache identity controlled?
  • is persistence part of the model's semantics, or merely a patch?
  • are SSR rules structural, or just conventions?
  • is resource-level complexity pulled back under one stable owner?

So the conclusion I would draw is this:

The ceiling of state management in a large project is usually not determined by one library. It is determined by whether the architecture forms a coherent state system.

The scattered version can be fast.

The systematic version can be steady.

The former depends more on discipline.

The latter depends more on structure.

That is the real value of Zova/Cabloy in large Vue projects: not one isolated capability, but a more consistent system for the things that drift apart most easily:

  • ownership,
  • sharing,
  • cache semantics,
  • persistence,
  • SSR,
  • resource boundaries.

That is why "Scattered vs Systematic" is more than a catchy title.

It points to the real engineering question underneath the tooling debate:

  • should complex state continue to be held together mainly by people,
  • or should more of it be held together by structure?

If I had to compress the whole article into one closing line, it would be this:

Large Vue projects usually do not need more state tools. They need a state architecture capable of organizing the tools they already have.


Further reading

If you want to keep following this topic, the most useful public references are:

  • Reading Zova for Vue Developers — a bridge from the Vue mental model to the Zova one
  • Zova vs Vue 3 Comparison — a broader side-by-side framing
  • IoC and Beans — the sharing boundaries behind ctx, app, sys, and host
  • Model Architecture — why Model is not just a query wrapper
  • Model State Guide — the data / mem / local / cookie / db state families
  • Model Resource Owner Pattern — why resource-level query, schema, permissions, and invalidation should be owned together

And if you are working on a Vue system that keeps getting larger and harder to explain, the most useful question may no longer be:

  • do we need one more state library?

It may be this instead:

  • can we turn this state system from a scattered assembly into a coherent architecture?

Top comments (0)