Global Store
The global store is a single shared data layer. Declare server fetchers once in pulse.store.js — user profiles, settings, feature flags — and any page can access them by name. No prop drilling, no repeated fetches.
When to use the store
The store is the right tool when the same server data is needed on multiple pages and it would be wasteful to redeclare the same fetcher in every spec:
- Current user / session —
store.user - App settings or feature flags —
store.settings - Navigation items that come from a CMS —
store.nav - Subscription or plan level —
store.plan
spec.server instead.Defining the store
Create a pulse.store.js file at the root of your project. Export a plain object with an optional state (default values) and a server map of async fetchers:
// pulse.store.js
export default {
// Default / fallback values used on the server before fetchers resolve
state: {
user: null,
settings: { theme: 'dark', lang: 'en' },
nav: [],
},
// Server fetchers — run once per request, results override state defaults
server: {
user: async (ctx) => db.users.findByCookie(ctx.cookies.session),
settings: async (ctx) => db.settings.forUser(ctx.cookies.userId),
nav: async () => cms.getNavItems(),
},
}
Registering the store
There is no registration step. pulse dev and pulse start auto-discover pulse.store.js from the project root — the same way pages are discovered from src/pages/. The store is loaded server-side, served to the browser for mutations, validated at startup (bad fetchers fail the boot), and hot-reloaded in dev when you edit it.
Only when you run your own server entry do you pass it manually:
import { createServer } from '@invisibleloop/pulse'
import store from './pulse.store.js'
createServer(pages, {
port: 3000,
staticDir: 'public',
store, // ← manual server entry only
})
Using store data in a page
Declare which store keys a page needs using the store field. Those keys are merged into the server argument of the view — alongside any page-level server data:
// src/pages/dashboard.js
export default {
route: '/dashboard',
store: ['user', 'settings'], // declare which store keys this page uses
// Page-level server data still works alongside store data
server: {
stats: async (ctx) => db.stats.forUser(ctx.store.user?.id),
},
state: { filter: 'week' },
view: (state, server) => `
<main>
<h1>Hello, ${server.user?.name ?? 'there'}</h1>
<p>Theme: ${server.settings.theme}</p>
<p>Stats: ${server.stats.total} requests this ${state.filter}</p>
</main>
`,
}
Only the keys listed in spec.store are available in the view — nothing leaks from the store to pages that do not declare a dependency on it. Page-level server keys always win if there is a name collision with the store.
Accessing the store in server fetchers
Store data is resolved before page server fetchers run. The full resolved store state is available as ctx.store in any page's server fetcher, guard, and meta functions:
export default {
route: '/account',
store: ['user'],
// Guard can use ctx.store to check auth before fetching page data
guard: async (ctx) => {
if (!ctx.store.user) return { redirect: '/login' }
},
// Server fetchers receive ctx.store with the resolved store state
server: {
orders: async (ctx) => db.orders.forUser(ctx.store.user.id),
},
view: (state, server) => `
<h1>Orders for ${server.user.name}</h1>
`,
}
Store field reference
| Field | Type | Description |
|---|---|---|
state | object | Default values. Used as fallbacks when a server fetcher returns undefined or the server key is absent. |
server | object of functions | Async fetchers — async (ctx) => value. Receive the same ctx as page server fetchers. Results override state defaults. |
spec.store field
| Field | Type | Description |
|---|---|---|
store | string[] | Array of store key strings to make available in the view's server argument. e.g. ['user', 'settings'] |
store receive no store data — the store never leaks to pages that do not ask for it.Reactive updates — no refresh needed
When a page action changes store data, all other mounted pages that subscribe to the affected keys re-render immediately — no page refresh, no polling.
Return _storeUpdate from a page action's onSuccess to push a change into the global store:
// src/pages/settings.js
export default {
route: '/settings',
store: ['settings'],
state: { saved: false },
actions: {
saveTheme: {
run: async (state, server, payload) => {
const theme = payload.get('theme')
await fetch('/api/settings', { method: 'PATCH', body: payload })
return theme
},
onSuccess: (state, theme) => ({
saved: true,
_storeUpdate: { settings: { theme } }, // ← push to global store
}),
onError: (state, err) => ({ error: err.message }),
},
},
view: (state, server) => `
<form data-action="saveTheme">
<select name="theme">
<option value="dark" ${server.settings.theme === 'dark' ? 'selected' : ''}>Dark</option>
<option value="light" ${server.settings.theme === 'light' ? 'selected' : ''}>Light</option>
</select>
<button type="submit">Save</button>
${state.saved ? '<p>Saved!</p>' : ''}
</form>
`,
}
Any other page that has store: ['settings'] will re-render with the new theme value the moment _storeUpdate is dispatched — without navigating away or refreshing.
_storeUpdate is stripped from the local page state — it is only forwarded to the store. The rest of the onSuccess return is merged into the page's own state as usual.Live store push — server-initiated updates
For data that changes on the server — stock levels, live scores, announcements — enable the live channel and broadcast with pushStore. Subscribed pages re-render the moment a patch arrives, with no polling and no client code:
// pulse.config.js — enable the channel
export default { live: true }
// Broadcast from any spec file — e.g. a webhook that updates stock:
import { pushStore } from '@invisibleloop/pulse'
export default {
route: '/hooks/stock',
contentType: 'text/plain',
state: {},
render: async (ctx) => {
const { stock } = await ctx.json()
pushStore({ stock }) // pages with store: ['stock'] re-render
return 'ok'
},
}
When you own the server entry, createServer also returns the handle directly: const { pushStore } = await createServer(pages, { live: true }).
The browser side is automatic: pages that declare store: [...] keys open a single EventSource connection per tab (default endpoint /__pulse/live). The connection survives client-side navigations and reconnects natively after network interruptions. Pages without store keys never connect.
_storeUpdate instead.server.stock ?? '—', not server.stock. The page must also be hydrated (have mutations or actions); purely server-rendered pages have no JavaScript to receive patches.Caching and performance
Store fetchers run once per request, in parallel. They share the same request context as page server fetchers, so they can read cookies, params, and headers to scope data to the current user.
If your store data changes infrequently (nav items from a CMS, feature flags), consider adding a serverTtl to the relevant page spec to cache the full rendered HTML — or caching inside the fetcher itself:
// pulse.store.js — cache nav items in-process for 60 seconds
import { createCache } from './src/lib/cache.js'
const navCache = createCache(60)
export default {
server: {
nav: async () => navCache.getOrFetch('nav', () => cms.getNavItems()),
},
}