Skip to content

Persistence

Persistence allows Presage to remember user traits and behavioral signals across page reloads and browser sessions.

The recommended driver for browser-based SaaS applications:

import { createAdaptiveClient, createLocalStorageDriver } from '@presage-kit/core'
const client = createAdaptiveClient({
persistence: {
driver: createLocalStorageDriver('my-app'),
},
// ...rules
})

Data is stored in localStorage under namespaced keys:

  • my-app:traits — User traits (role, plan, etc.)
  • my-app:signals — Behavioral signals

The prefix prevents collisions with other libraries or multiple Presage instances on the same domain.

An in-memory driver for SSR environments and testing:

import { createAdaptiveClient, createMemoryDriver } from '@presage-kit/core'
const client = createAdaptiveClient({
persistence: {
driver: createMemoryDriver(),
},
// ...rules
})

Data lives only in memory and is lost when the process ends. This is useful for:

  • Server-side renderinglocalStorage is not available on the server
  • Unit tests — No side effects between test runs
  • Storybook — Isolated stories that don’t pollute browser storage
KeyDataWhen updated
traitsUserTraits objectOn identify(), updateTraits(), and any state change
signalsBehavioralSignals objectAfter signal recomputation (100ms after track())

Implement the StorageDriver interface to store data anywhere:

interface StorageDriver {
get<T>(key: string): T | null
set<T>(key: string, value: T): void
remove(key: string): void
clear(): void
}
import type { StorageDriver } from '@presage-kit/core'
function createSessionStorageDriver(prefix: string): StorageDriver {
function prefixed(key: string): string {
return `${prefix}:${key}`
}
return {
get<T>(key: string): T | null {
const raw = sessionStorage.getItem(prefixed(key))
if (raw === null) return null
try {
return JSON.parse(raw) as T
} catch {
return null
}
},
set<T>(key: string, value: T): void {
sessionStorage.setItem(prefixed(key), JSON.stringify(value))
},
remove(key: string): void {
sessionStorage.removeItem(prefixed(key))
},
clear(): void {
const toRemove: string[] = []
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i)
if (key?.startsWith(`${prefix}:`)) {
toRemove.push(key)
}
}
for (const key of toRemove) {
sessionStorage.removeItem(key)
}
},
}
}
import type { StorageDriver } from '@presage-kit/core'
function createCookieDriver(prefix: string): StorageDriver {
return {
get<T>(key: string): T | null {
const name = `${prefix}_${key}`
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
if (!match) return null
try {
return JSON.parse(decodeURIComponent(match[1])) as T
} catch {
return null
}
},
set<T>(key: string, value: T): void {
const name = `${prefix}_${key}`
const encoded = encodeURIComponent(JSON.stringify(value))
document.cookie = `${name}=${encoded}; path=/; max-age=31536000; SameSite=Lax`
},
remove(key: string): void {
const name = `${prefix}_${key}`
document.cookie = `${name}=; path=/; max-age=0`
},
clear(): void {
// Clear all cookies with the prefix
const cookies = document.cookie.split('; ')
for (const cookie of cookies) {
const name = cookie.split('=')[0]
if (name.startsWith(`${prefix}_`)) {
document.cookie = `${name}=; path=/; max-age=0`
}
}
},
}
}

A common problem with persisted state is the flash of unconditioned content (FOUC): the UI renders with default values, then flashes to the personalized state once data loads.

Presage avoids this because the StorageDriver.get() method is synchronous. When createAdaptiveClient() is called:

  1. The driver reads cached traits and signals synchronously
  2. Maturity is computed from cached signals
  3. The initial UserContext is ready before the first render
  4. Components receive the correct variant from the very first paint
// This is synchronous — no loading state needed
const client = createAdaptiveClient({
persistence: {
driver: createLocalStorageDriver('my-app'),
},
rules: [...],
})
// client.getContext() already has cached data
console.log(client.getContext().traits) // { userId: 'user-123', role: 'admin', ... }

This design is intentional. Both localStorage and sessionStorage are synchronous APIs, which makes them ideal for anti-FOUC. If you build a custom async driver (e.g., IndexedDB), you will need to handle the loading state yourself.

If you do not configure a persistence driver, Presage still works — but the user context resets on every page load. This can be useful for:

  • Prototyping and demos
  • Server-rendered applications where context comes from the server
  • Scenarios where you always identify() from your auth system on mount
// No persistence — context resets on reload
const client = createAdaptiveClient({
rules: [...],
})