Skip to content

FrontEnd Architecture: Modular State with Pinia #6522

@cstns

Description

@cstns

Pinia Migration Plan

A comprehensive roadmap for migrating the FlowFuse frontend from Vuex 4 to Pinia.


TLDR

Migrate the frontend state management from Vuex 4 to Pinia by converting each Vuex module into a standalone Pinia store, replacing mutations with direct state updates in actions, implementing pinia-plugin-persistedstate for local/session storage, and migrating stores in dependency order (leaf modules first, account stores last). During the transition, Vuex and Pinia will coexist using a temporary bridge pattern until all 238 store consumers are updated.


Key Differences: Vuex → Pinia

Concept Vuex Pinia
Mutations Required for all state changes Eliminated — state is mutated directly in actions
Namespacing String-based: 'account/setTeam' Import the store: useAccountStore()
Cross-store access rootState, rootGetters params Import the other store directly inside the function
Composition API useStore() + string keys const store = useSpecificStore()
Options API mapState, mapGetters, mapActions storeToRefs() in setup(), or mapStores()
Global reset $resetState action dispatched to all modules Each store gets its own $reset()
Plugins One global plugin Plugin registered on the Pinia instance
Testing Requires full store wiring Each store is independently unit-testable

File Structure & Naming Convention

Pinia stores live in a flat frontend/src/stores/ directory (distinct from the existing frontend/src/store/ Vuex directory). Since Pinia has no concept of namespacing, the file names carry the domain grouping instead.

Convention: dash-separated domain prefix

frontend/src/stores/
├── account-*.js          # split TBD — see "Splitting the Account Store" below
├── context.js
├── ux-dialog.js
├── ux-drawers.js
├── ux-navigation.js      # formerly ux root module
├── ux-tours.js
├── product-brokers.js    # formerly product root module
├── product-assistant.js
├── product-expert.js
├── product-expert-ff-agent.js
├── product-expert-operator-agent.js
└── product-tables.js

The defineStore id matches the filename: defineStore('ux-dialog', ...), defineStore('ux-drawers', ...). This means the Pinia devtools and persisted localStorage keys are consistent and human-readable.

Why dash notation over dot notation: Dots in filenames look like file extensions and confuse some tooling. Dashes are the standard convention across the Vue/Pinia community for flat domain-prefixed stores. It also mirrors how CSS classes and URL slugs are commonly structured.

Why not subdirectories: Subdirectories add import path complexity (@/stores/ux/dialog vs @/stores/ux-dialog) and require index files to re-export. For a migration, keeping everything at one level makes it easier to audit what's been migrated and what hasn't.


Splitting the Account Store

The account Vuex module is a single file with 15 state properties, 28 getters, 19 mutations, and 17 actions — too large for a single Pinia store. It needs to be split into smaller, focused stores before migration.

The split strategy is TBD and should be decided as a team before this phase begins. Natural fault lines to consider when making that decision:

  • Auth vs. team context — user identity/session data vs. which team is active and what role the user has in it
  • Settings/featuressettings and features power the featuresCheck getter (31 properties) and may warrant their own store given how widely that getter is consumed
  • Notifications — relatively self-contained, low dependency surface

The main constraint to keep in mind: featuresCheck reads from both settings/features and the current team, so whatever stores those values must either be co-located or have a clear import order to avoid circular dependencies.

The file structure above uses account-auth.js and account-team.js as placeholders — the actual filenames should reflect whatever split is agreed on.


Persistence & Hydration (The localStorage Problem)

This is the most technically sensitive part of the migration. Getting it wrong means users lose their session on page reload or get stale state after logout.

How it works today (Vuex)

The existing store/plugins/storage.plugin.js does two things:

  1. On init: reads all persisted keys from localStorage/sessionStorage and patches them back into the store state before the app mounts
  2. On every mutation: checks if the mutated state path has a persistence config and writes the new value to the correct storage

Each module declares its persistence config in a meta.persistence object:

meta: {
  persistence: {
    team: { storage: 'sessionStorage' },
    settings: { storage: 'localStorage', clearOnLogout: false }
  }
}

The bootstrap service waits for a HYDRATE_COMPLETE mutation (or the state._hydrated flag) before considering the store ready and proceeding with user auth checks.

What changes with Pinia

Pinia has no global plugin that intercepts mutations. Instead, each store declares its own persistence config using pinia-plugin-persistedstate. The plugin handles read-on-init and write-on-change per store.

Install:

npm install pinia pinia-plugin-persistedstate@4
npm install -D @pinia/testing

Register on the Pinia instance:

// main.js
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

Each store with persistence adds a persist option:

// account-team.js
export const useAccountTeamStore = defineStore('account-team', {
  state: () => ({ team: null, teamMembership: null, settings: null, features: {} }),
  persist: [
    { pick: ['settings', 'features'], storage: localStorage },
    { pick: ['team', 'teamMembership'], storage: sessionStorage }
  ]
})

The pick array lists only the keys that should be persisted — anything not listed stays in memory only.

The clearOnLogout: false problem

In Vuex, $resetState is called globally on logout and a clearOnLogout: false flag tells it to skip certain keys. Pinia has no equivalent flag — $reset() is all-or-nothing per store.

Only settings and features in the account module have clearOnLogout: false today, but this list is likely to grow. Three options exist — Option C is the agreed approach:

Option C — skipReset Pinia plugin (recommended)

Register a global Pinia plugin in Phase 0 that wraps $reset() on every store. Stores declare a skipReset array listing state keys that survive logout. Logout calls $reset() on all stores uniformly — the plugin handles preservation automatically.

// frontend/src/stores/plugins/skip-reset.plugin.js
export function skipResetPlugin ({ store, options }) {
    const skipKeys = options.skipReset || []
    const originalReset = store.$reset?.bind(store)

    store.$reset = () => {
        const preserved = {}
        skipKeys.forEach(key => { preserved[key] = store[key] })
        if (typeof originalReset === 'function') originalReset()
        store.$patch(preserved)
    }
}
// main.js
import { skipResetPlugin } from './stores/plugins/skip-reset.plugin.js'
pinia.use(skipResetPlugin)

Usage in a store:

export const useAccountTeamStore = defineStore('account-team', {
    state: () => ({
        team: null,
        teamMembership: null,
        features: {}
    }),
    skipReset: ['features'],  // features survives $reset()
    // ...
})

Logout becomes uniform — call $reset() on every store, no exceptions needed:

async logout() {
    await userApi.logout()
    useAccountAuthStore().$reset()
    useAccountTeamStore().$reset()    // features preserved by plugin
    useContextStore().$reset()
    useUxDrawersStore().$reset()
    useUxToursStore().$reset()
    useProductBrokersStore().$reset()
    useProductExpertStore().$reset()
    useProductTablesStore().$reset()
}

Why this over Options A and B: As more state keys accumulate clearOnLogout: false behavior, Option A requires more store splits and Option B requires manually maintained reset lists per store. The plugin declares intent once in the store definition and scales without extra work.


Option A — Split the store

Put settings and features in their own store (e.g. account-settings). During logout, simply don't call $reset() on it. No plugin needed. Works well if the split is happening anyway for other reasons.

Option B — Override $reset() in the store

Define a custom $reset action that skips certain keys. This shadows the built-in $reset(). The tradeoff: you lose automatic reset-to-initial-state behavior and must maintain the reset list manually as state keys are added.

The hydration timing problem

This is simpler than it first appears. pinia-plugin-persistedstate restores state synchronously at store creation time — the moment useXxxStore() is called, state is already restored from localStorage. There's no async waiting needed.

During the migration (Tasks 1–11), bootstrap still uses Vuex for auth and user state, so Pinia stores self-hydrate lazily when components first use them. No coordination is needed.

At Task 12 (account stores), bootstrap needs account data before mounting. The solution is to eagerly call the account stores before checkUser() runs:

// bootstrap.service.js — Task 12 final form
async init () {
    return this.waitForAppMount()
        .then(() => {
            // Eagerly create account stores — restores from localStorage synchronously
            useAccountAuthStore()
            useAccountTeamStore()
            useAccountSettingsStore()
        })
        .then(() => this.checkUser())
        .then(() => this.mountApp())
        .then(() => this.waitForRouterReady())
        .then(() => this.markAsReady())
}

waitForStoreHydration() is removed entirely at Task 12 — Vuex is gone, and Pinia doesn't need it.

The localStorage key change

The current Vuex plugin stores all persisted state under a single store key in localStorage as a nested object. pinia-plugin-persistedstate uses the store's id as the key by default (e.g. account-team, ux-tours).

This means existing users' localStorage is silently abandoned on first load after the migration deploys — they won't be logged out (that's determined server-side), but any locally-cached state (tours completed, team context, feature flags) will be gone and re-fetched. This is acceptable behavior for a one-time migration. No migration script is needed.


Store Overview & Migration Order

Migrate leaf modules first (no cross-store dependencies), save the account stores for last since everything else depends on them.

Store (new name) Vuex module Persistence Dependencies Complexity Consumers
context context None None Low routes.js, expert getter
ux-dialog ux/dialog None None Low dialog service, Dialog mixin, 50+ pages
ux-tours ux/tours localStorage None Low App.vue, onboarding pages
ux-drawers ux/drawers None ux-navigation (overlay) Medium 100+ components
ux-navigation ux root localStorage account stores (nav tree) Medium MainNav.vue
product-tables product/tables localStorage account stores (team.id) Medium 20+ Tables pages
product-assistant product/assistant None account stores (settings/origins) Medium-High Expert assistant components
product-expert-ff-agent product/expert/ff-agent localStorage None (leaf) Low product-expert only
product-expert-operator-agent product/expert/operator-agent localStorage account stores (capabilities) Low product-expert only
product-expert product/expert localStorage ux-drawers, ff-agent, operator-agent High Expert UI components
product-brokers product root localStorage account stores (team.id) Medium Broker pages, PostHog
account stores (split TBD) account localStorage + sessionStorage each other Very High routes.js, App.vue, bootstrap, 80+ components

What Needs to Change: Full Surface Area

main.js

  • Create and register Pinia instance with pinia-plugin-persistedstate
  • Remove store.commit('initializeStore')
  • Keep app.use(vuexStore) until all modules migrated, then remove

services/bootstrap.service.js

  • During migration (Tasks 1–11): waitForStoreHydration() continues to wait only for Vuex HYDRATE_COMPLETE — no Pinia changes needed
  • At Task 12: Remove waitForStoreHydration() entirely, eagerly call account stores before checkUser(), replace store.dispatch('account/checkIfAuthenticated') with useAccountAuthStore().checkIfAuthenticated()

routes.js and route guards

  • Replace useStore() from vuex with imports from specific Pinia stores
  • ensureAdmin watches user.admin — replace Vuex watcher with Pinia watch()
  • ensurePermission watches settings — same pattern

App.vue

  • Largest single component consumer of the store
  • Uses mapState and mapGetters from account, ux, ux/drawers
  • Move all of these into a setup() function using storeToRefs

services/dialog.js

  • Currently dispatches Vuex actions — replace with useUxDialogStore() calls directly

mixins/Dialog.js and mixins/Features.js

  • Both are thin wrappers around store access
  • After migration, replace with composables: useDialog(), useFeatures()
  • This is an optional cleanup but removes an entire layer of indirection

components/drawers/navigation/MainNav.vue

  • Primary consumer of mainNavContexts getter
  • This getter is the most complex in the codebase — drives the entire sidebar; must be verified against all role types after migration

All 238+ store-accessing files

Find them with:

grep -rl "from 'vuex'\|mapState\|mapGetters\|mapActions\|mapMutations\|this\.\$store\|useStore()" frontend/src/

Two update patterns depending on component style:

Composition API (<script setup> or setup()):

import { storeToRefs } from 'pinia'
import { useAccountTeamStore } from '@/stores/account-team'

const { team, featuresCheck } = storeToRefs(useAccountTeamStore())
const { setTeam } = useAccountTeamStore() // actions don't need storeToRefs

Options API (no setup()):

import { mapStores } from 'pinia'
import { useAccountTeamStore } from '@/stores/account-team'

computed: {
  ...mapStores(useAccountTeamStore),
  // Access via this.accountTeamStore.team
}

Dividing Up the Work

Each store migration is designed to be one PR — create store, update consumers, delete Vuex module. Nothing is left in a half-migrated state between merges.

Hard gates:

  • Phase 0 must merge before any store PR
  • ux-navigation must merge before ux-drawers
  • Account stores must be last — everything else imports them

Within those gates, stores can be worked in parallel by different engineers. Group them by domain for ownership:

Group Stores Internal ordering
UX ux-dialog, ux-tours, ux-navigation, ux-drawers navigation before drawers; dialog and tours are free
Product product-tables, product-brokers, product-assistant any order
Expert product-expert-ff-agent, product-expert-operator-agent, product-expert sub-agents before parent
Infrastructure context, route guards, bootstrap service any order after Phase 0
Account account stores (split TBD) + 80+ consumer updates last

The bridge pattern (described below) is what makes parallel work safe — Pinia stores that need account data read it from Vuex temporarily until the account stores are migrated.


Shipping Order

Each numbered task below is intended to be one PR. They must be merged in this order:

PR Task Gate
1 Phase 0 — Infrastructure None — start here
2 ux-dialog Phase 0 merged
3 ux-tours Phase 0 merged
4 ux-navigation Phase 0 merged
5 ux-drawers ux-navigation merged
6 context Phase 0 merged
7 product-tables Phase 0 merged
8 product-brokers Phase 0 merged
9 product-assistant Phase 0 merged
10 product-expert-ff-agent Phase 0 merged
11 product-expert-operator-agent Phase 0 merged
12 product-expert PRs 5, 10, 11 merged
13+ Account stores All other PRs merged

PRs 2–4 and 6–11 can all be worked in parallel once Phase 0 is merged, subject to their individual gates. PR 5 needs PR 4. PR 12 needs PRs 5, 10, and 11. Account stores go last.


The Cross-Store Bridge Pattern (During Migration)

While the account stores are still on Vuex, other Pinia stores that need team/user data use a temporary bridge import:

// Temporary: import Vuex store directly during migration
import store from '@/store' // the old Vuex store
const teamId = store.state.account.team?.id

// After account-team is migrated, replace with:
import { useAccountTeamStore } from '@/stores/account-team'
const { team } = storeToRefs(useAccountTeamStore())

Similarly, the Vuex account module's clearOtherStores action needs a bridge while product stores are being migrated:

// In Vuex account module (temporary)
clearOtherStores({ dispatch }) {
  // Keep old dispatch for modules not yet migrated
  dispatch('product/tables/clearState')
  // Call Pinia store once it's migrated
  if (window.__pinia) {
    const tablesStore = useProductTablesStore()
    tablesStore.clearState()
  }
}

Gotchas

Non-serializable values in state

product-expert stores AbortController, setInterval references, and timer IDs in state. Vue's Proxy breaks these. Wrap with markRaw():

this.abortController = markRaw(new AbortController())
this.streamingTimer = markRaw(setInterval(...))

Circular imports

account-team imports account-auth, and other stores import account-team. If two stores ever end up importing each other, import the secondary store inside the function body rather than at the top of the file to break the cycle:

someAction() {
  const other = useOtherStore() // inside function, not top-level import
}

Getters that return functions

Some Vuex getters return a function (e.g. brokerExpandedTopics(brokerId)). Pinia supports this pattern identically:

brokerExpandedTopics: (state) => (brokerId) => {
  const { team } = useAccountTeamStore()
  return state.brokers.expandedTopics?.[team?.id]?.[brokerId] ?? []
}

$reset() only works with Options API stores

$reset() is built into Pinia's Options API store definition (defineStore(id, { state, getters, actions })). It resets all state to the initial value returned by the state factory. It is not available on Setup stores (defineStore(id, () => {...})). All stores in this migration use Options API, so $reset() is available — but any new stores added later must also use Options API if they need $reset().

storeToRefs is required for reactive destructuring

// WRONG — loses reactivity
const { user } = useAccountAuthStore()

// CORRECT — reactive
const { user } = storeToRefs(useAccountAuthStore())

// Actions are plain functions — destructure directly
const { logout } = useAccountAuthStore()

Testing Pinia Stores

One of Pinia's key advantages over Vuex is that each store is independently testable without wiring up the entire app.

Unit testing a store (no dependencies)

import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach } from 'vitest'
import { useUxDialogStore } from '@/stores/ux-dialog.js'

describe('ux-dialog store', () => {
  beforeEach(() => {
    setActivePinia(createPinia()) // fresh Pinia instance per test
  })

  it('initializes with empty state', () => {
    const store = useUxDialogStore()
    expect(store.dialog.header).toBeNull()
  })

  it('clearDialog resets state', () => {
    const store = useUxDialogStore()
    store.dialog.header = 'Test'
    store.clearDialog()
    expect(store.dialog.header).toBeNull()
  })
})

Unit testing a store that depends on another store

If a store reads from another Pinia store, just call both in the same beforeEach:

import { setActivePinia, createPinia } from 'pinia'
import { useProductAssistantStore } from '@/stores/product-assistant.js'
import { useContextStore } from '@/stores/context.js'

beforeEach(() => {
  setActivePinia(createPinia())
})

it('immersiveInstance reads from contextStore', () => {
  const context = useContextStore()
  const assistant = useProductAssistantStore()
  context.setInstance({ id: 'inst-1', url: 'http://localhost' })
  expect(assistant.immersiveInstance).toEqual({ id: 'inst-1', url: 'http://localhost' })
})

Mocking stores in component tests

Use @pinia/testing to stub stores in Vue component tests. This prevents real state from leaking between tests and lets you control what the store returns:

import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import MyComponent from '@/components/MyComponent.vue'
import { useAccountTeamStore } from '@/stores/account-team.js'

it('renders team name', () => {
  const wrapper = mount(MyComponent, {
    global: {
      plugins: [
        createTestingPinia({
          createSpy: vi.fn,
          initialState: {
            'account-team': { team: { id: 't1', name: 'My Team' } }
          }
        })
      ]
    }
  })

  const store = useAccountTeamStore()
  expect(wrapper.text()).toContain('My Team')
  // Actions are auto-stubbed — verify they were called:
  store.setTeam({ id: 't2' })
  expect(store.setTeam).toHaveBeenCalledWith({ id: 't2' })
})

Key rule: createTestingPinia stubs all actions by default. Pass stubActions: false if you need the real action logic to run.

Note — Issue #6457: This migration is a deliberate opportunity to establish solid frontend unit testing patterns. Each store PR should be treated as a "start fresh" moment for its test coverage — don't just port existing tests, write tests that actually cover the store's contract.


Definition of Done

  • npm uninstall vuex succeeds with no errors
  • grep -r "from 'vuex'" returns zero results
  • grep -r "this\.\$store" returns zero results
  • frontend/src/store/ directory deleted in its entirety
  • All persisted state survives a page reload
  • Logout clears correct state and preserves settings and features
  • All route guards work correctly for all role types
  • Expert assistant streaming and session timers work end-to-end
  • Full navigation tree renders correctly for Guest, Member, Owner, Admin roles
  • Each migrated store has a frontend/src/tests/stores/*.spec.js file
  • Existing Vitest and Cypress tests pass

Sub-issues

Metadata

Metadata

Assignees

Labels

storyA user-oriented description of a feature

Type

No type

Projects

Status

In Progress

Status

No status

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions