-
Notifications
You must be signed in to change notification settings - Fork 83
Description
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/features —
settingsandfeaturespower thefeaturesCheckgetter (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:
- On init: reads all persisted keys from localStorage/sessionStorage and patches them back into the store state before the app mounts
- 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/testingRegister 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 VuexHYDRATE_COMPLETE— no Pinia changes needed - At Task 12: Remove
waitForStoreHydration()entirely, eagerly call account stores beforecheckUser(), replacestore.dispatch('account/checkIfAuthenticated')withuseAccountAuthStore().checkIfAuthenticated()
routes.js and route guards
- Replace
useStore()fromvuexwith imports from specific Pinia stores ensureAdminwatchesuser.admin— replace Vuex watcher with Piniawatch()ensurePermissionwatchessettings— same pattern
App.vue
- Largest single component consumer of the store
- Uses
mapStateandmapGettersfromaccount,ux,ux/drawers - Move all of these into a
setup()function usingstoreToRefs
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
mainNavContextsgetter - 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 storeToRefsOptions 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-navigationmust merge beforeux-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 vuexsucceeds 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
settingsandfeatures - 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.jsfile - Existing Vitest and Cypress tests pass
Sub-issues
Metadata
Metadata
Assignees
Labels
Type
Projects
Status
Status