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.