mibudge

SPA state

Where state lives in the SPA, how the Pinia stores cache server data and keep it current, and what happens on sign-out. The stores are in frontend/src/stores/; see architecture.md for how they fit with the other layers.


The stores

All stores are setup stores (defineStore(id, () => { ... })). Each holds models from models/, never DTOs, and defines reset().

Store (use…Store) File Responsibility
session stores/session.ts The in-memory access token, the signed-in user, isAuthenticated and the profile timezone. Sign-in (login), token renewal (renewToken, refresh), loadUser, updateProfile, logout, endSession. Also builds the session-wired HTTP client (createSessionHttpClient).
bankAccounts stores/bankAccounts.ts The user’s bank accounts, in list order. loadAll, refresh, invalidate, fetchOne, create, update, remove; read with all and byId.
accountContext stores/accountContext.ts Which account the user is looking at: activeBankAccountId, activeBankAccount, unallocatedBudgetId. init() picks the account: the one stored for this tab, else the user’s default, else the first. refresh() moves off a deleted account. The choice persists per tab in sessionStorage.
budgets stores/budgets.ts Budgets keyed by id. fetchOne, fetchList (every page), refreshAccount, create, update, archive, transfer; read with byId, forAccount, names.
transactionNav stores/transactionNav.ts The ids of the rows the transaction list last showed, so the detail page can step to the previous or next row. It also keeps the list’s search and filter across a visit to the detail page.

stores/reset.ts is not a store. It holds the reset plugin (below).


Caching and invalidation

The entity caches (bankAccounts, budgets) follow the same rules:

A store does not hold loading or error state for a single page. The budgets store’s loading / error describe its last list fetch; per-page state comes from useResource in the feature composable.


Settings that save on change

A control that saves as soon as the user changes it (a toggle, a select) is optimistic: the new value shows at once and the request runs in the background. The feature composable wraps the setting in useOptimistic(source, commit, options?) from composables/useOptimistic.ts. It never hand-rolls a pending value.

// features/bankAccounts/useBankAccountDetail.ts
const autoFunding = useOptimistic(
  (accountId: string) => accounts.byId(accountId)?.autoFundingEnabled ?? false,
  async (accountId: string, enabled: boolean) => {
    await accounts.update(accountId, { autoFundingEnabled: enabled });
  },
  { errorMessage: "Failed to change automatic funding." },
);
const autoFundingEnabled = computed(() =>
  account.value ? autoFunding.value(account.value.id) : false,
);

A text field that saves after the user stops typing uses useDebouncedAutosave instead.


Sign-out resets every store

main.ts and tests/setup.ts install resetPlugin from stores/reset.ts on the Pinia. The plugin records each store as it is created. resetAllStores(pinia) then calls every recorded store’s reset().

session.endSession() calls resetAllStores. It runs on:

After a reset no cached account, budget, navigation list or stored active-account id survives into the next user’s session in the same tab.

Every store must define reset() that returns all of its state to the initial values (and clears anything it persisted). tests/stores/reset.test.ts globs src/stores/*.ts and fails if any store has no reset. It also seeds every store, signs out, and checks that every store is empty and the tab’s stored account is gone. When you add a store, add its state to that sign-out test.

A request in flight at sign-out must not write afterwards. A store that caches server answers creates a guard with createSessionGuard() (stores/reset.ts), calls guard.bump() in reset(), and wraps each cache write in guard.whileCurrent(...) before it awaits the request. The budgets and bank-accounts stores do this; the session store compares its own in-flight promise instead. tests/stores/reset.test.ts checks that a late answer leaves the store empty.


Store vs composable vs local state

Put it in… When Examples
A store (stores/) More than one route or section reads it, it must outlive the page, or one section must see another’s change. The session, the active account, the budget cache the top bar and budget pages share, the transaction list’s row order for the detail page’s prev/next.
A feature composable (features/<section>/use*.ts) Data and actions belonging to one section. It loads through api or a store, holds the section’s form state, and exposes computed views of it. useBudgetDetail, useMoveMoney, useApiKeys, useTransactionList.
A shared composable (composables/) Behaviour that several features reuse and that carries no data of its own. useResource, useInfiniteList, useModal, useFormErrors, useOptimistic, useDebouncedAutosave.
Local component state (ref in the SFC) Pure UI state nothing else needs. Which tab is active, whether a sheet is open, an input’s draft text.

Rules of thumb:


Testing stores

See testing.md for more.