Skip to main content

Store

A small key-value store for state your script owns — a score, a quiz answer, a "they dismissed the banner" flag. You can subscribe to a key and update the page whenever it changes, and optionally have the state survive a reload.

It's independent of the experience: no globals, no setup, import it like anything else.

import { connect, createStore } from '@ceros/flex-experience-sdk'

const experience = await connect()
const store = createStore({ score: 0 })

store.set('score', 10)
store.get('score') // 10

Do you need it? If your state is a couple of interdependent values you read and write in one place, a plain object is simpler — see the calculator example. The store earns its keep when you want to subscribe to a value and re-render from one place, or when the state should outlive a reload.

createStore(initialValues, options?)

function createStore<S extends Record<string, unknown>>(
initialValues: S,
options?: { persist?: PersistAdapter<S> },
): Store<S>
  • initialValues — the starting state. Its keys define the store's shape.
  • options.persist — pass an adapter to save and restore the state. Omit it for in-memory state.

The store API

interface Store<S> {
get<K>(key: K): S[K]
set<K>(key: K, value: S[K]): void
update<K>(key: K, updater: (prev: S[K]) => S[K]): void
subscribe<K>(key: K, subscriber: (value: S[K]) => void): () => void
}
MethodWhat it does
get(key)Returns the current value.
set(key, value)Sets the value. Does nothing if the value is unchanged.
update(key, fn)Sets the value to fn(current). Same skip-if-unchanged rule.
subscribe(key, cb)Calls cb on every change to that key. Returns a function to unsubscribe. Many subscribers per key are fine.

Setting a key to the value it already holds notifies nobody — so you can call set defensively in a loop without churn. The comparison is by identity, so a new object or array always counts as a change, even if its contents match.

Subscribe and render

The pattern that makes the store worthwhile: one subscriber owns how a value appears on the page, and everything else just updates the value.

const store = createStore({ count: 0 })
const counter = experience.findByLocator('counter')

store.subscribe('count', (count) => {
counter.text.setText(String(count))
})

experience.findByLocator('increment').on('component.click', () => {
store.update('count', (n) => n + 1) // the subscriber redraws
})

subscribe doesn't fire on registration, so paint the initial value yourself if you need it on screen before the first change:

counter.text.setText(String(store.get('count')))

A subscriber that throws is caught and logged; it never stops the other subscribers for that key, or the set() call itself.

Persisting across reloads

Pass an adapter and the store loads saved state when it's created, then saves after every change.

import { createStore, localStorageAdapter } from '@ceros/flex-experience-sdk'

const store = createStore(
{ score: 0 },
{ persist: localStorageAdapter('mygame.save') },
)
function localStorageAdapter<S>(key: string, options?): PersistAdapter<S>
function sessionStorageAdapter<S>(key: string, options?): PersistAdapter<S>
  • localStorageAdapter — survives reloads and browser restarts on the same origin.
  • sessionStorageAdapter — survives reloads in the same tab, but not closing it.
  • Both store the whole state as JSON under the key you choose. Use a distinct key per store: two stores sharing a key on the same origin would overwrite each other, so prefixing with your experience or product name is a good habit. An empty key throws straight away rather than failing quietly later.
  • Override the JSON handling with { serialize, deserialize } if you need a different format.
  • Neither syncs live between tabs. State is loaded once when the store is created and written on each change.

How saved state merges

Saved values win for the keys they cover, and initialValues fills in the rest. So adding a new key to your store later won't discard what visitors already have stored.

When storage isn't available

Private browsing, a full quota, or a sandboxed iframe can all make storage fail. The store catches it, logs one warning, and carries on in memory for the rest of the visit — the experience keeps working, it just won't remember anything next time.

Custom storage

Any object with these two methods is a valid backend — a cookie, IndexedDB, your own API:

interface PersistAdapter<S> {
load(): Partial<S> | null
save(snapshot: Readonly<S>): void
}

load() returns state to merge in (or null for nothing saved); save() receives the whole current state. Either may throw — the store catches it, warns once, and continues in memory.

const cookieAdapter = (name) => ({
load: () => {
const raw = document.cookie.match(`(^|;)\\s*${name}=([^;]*)`)?.[2]
return raw ? JSON.parse(decodeURIComponent(raw)) : null
},
save: (snapshot) => {
const value = encodeURIComponent(JSON.stringify(snapshot))
document.cookie = `${name}=${value};path=/;max-age=31536000`
},
})

const store = createStore({ seen: false }, { persist: cookieAdapter('promo') })
note

The store saves your state to the visitor's browser. It never writes back to your Ceros experience — see Limits.