Use this document to understand how CoreStateful stores its internal state, why that state is
protected from outside interference, and which surface the SDK explicitly provides for consumers to
observe and influence state. Every mechanism described here is grounded in SDK source so you can
reason about runtime behavior with confidence.
For installation and setup, see companion guides such as Integrating the Optimization Web SDK in a web app. Use this document when you need to understand why state works the way it does, or when you want to extend behavior through the consumer-facing channels the SDK provides.
CoreStateful stores all runtime state in Preact Signals, a
lightweight reactive primitive that pushes value changes to dependent effects and computed values
automatically. Signals live at module scope inside core-sdk, which means all code sharing the same
JavaScript module graph reads the same values.
CoreStateful wraps each signal with the Observable adapter before exposing it on the states
property. Consumers see observables, not signals. The distinction matters: observables deep-clone
every emitted value before delivering it, so consumer-side mutations never reach the internal
signal.
CoreStateful maintains the following signals:
| Signal | Type | Description |
|---|---|---|
consent |
boolean | undefined |
Whether the user has granted or denied tracking consent. undefined means the value has not yet been set. |
persistenceConsent |
boolean | undefined |
Whether durable profile-continuity storage is allowed. undefined means the value has not yet been set. |
profile |
Profile | undefined |
The active user profile returned by the Experience API, including ID, traits, and location. |
selectedOptimizations |
SelectedOptimizationArray | undefined |
The set of variant selections returned by the Experience API for the current profile. |
changes |
ChangeArray | undefined |
The optimization change payload returned by the Experience API, used to resolve Custom Flag values and optimized entries. |
canOptimize |
boolean |
A computed signal derived from selectedOptimizations. true when variant data is available. |
event |
InsightsEvent | ExperienceEvent | undefined |
The most recent event emitted to either API. |
blockedEvent |
BlockedEvent | undefined |
Metadata about the most recent event that was blocked by consent gating. |
online |
boolean | undefined |
Runtime network connectivity, used by the queue flush logic. Defaults to true. |
previewPanelAttached |
boolean |
Whether the Contentful preview panel bridge has been registered. |
previewPanelOpen |
boolean |
Whether the preview panel UI is open. |
CoreStateful enforces a single-instance constraint for each JavaScript runtime using a
globalThis-based lock. Constructing a second instance before destroy() is called on the first
throws an error:
Stateful Optimization SDK already initialized (CoreStateful#1). Only one stateful instance is supported per runtime.
Because all signals are module-scoped, multiple instances can interfere with each other's state.
Call destroy() before re-initializing, for example during hot-module replacement or test teardown.
Internal signals are implementation details. Application code must use the supported consumer surfaces instead of writing signal values directly. The public consumer-facing entry points that can change Core state or event streams are:
| Method or surface | What it affects |
|---|---|
consent(accept) |
Sets event consent and, when provided, durable profile-continuity persistence consent. |
identify(payload) |
Sends an Experience event; updates profile, selectedOptimizations, and changes on response. |
page(payload) |
Sends an Experience event; same response-driven updates. |
screen(payload) |
Sends an Experience event; same response-driven updates. |
track(payload) |
Sends an Experience event; same response-driven updates. |
trackView(payload) |
Sends an Insights event; optionally sends an Experience event for view-triggered optimization. |
trackClick(payload) |
Sends an Insights event. |
trackHover(payload) |
Sends an Insights event. |
trackFlagView(payload) |
Sends an Insights event recording a Custom Flag observation. |
getFlag(name) |
Resolves a Custom Flag value from changes; emits a flag view event when the resolved value changes. |
states.flag(name).current |
Reads the current Custom Flag value and emits a flag view event for that read. |
states.flag(name).subscribe() |
Subscribes to distinct Custom Flag values and emits a flag view event for each delivered value. |
states.flag(name).subscribeOnce() |
Waits for the first non-nullish Custom Flag value and emits a flag view event for that value. |
reset() |
Clears blockedEvent, event, changes, profile, and selectedOptimizations in a single batch. |
flush() |
Triggers immediate queue flushes without writing to any signal directly. |
destroy() |
Flushes both queues and releases the singleton lock. |
The package also exports raw signals and signalFns references for SDK layers and first-party
preview tooling. Those exports are not application consumer APIs. Application code must treat them
as read-only implementation details and use the methods, observables, defaults, and interceptors
described in this document.
When identify, page, screen, track, or trackView sends an Experience event and the API
responds, ExperienceQueue calls updateOutputSignals(). That method runs the response through any
registered state interceptors and then writes to profile, selectedOptimizations, and changes
in a single reactive batch:
Consumer calls sdk.page()
-> CoreStatefulEventEmitter builds event
-> ExperienceQueue runs event interceptors, validates the event, and updates the event signal
-> ExperienceQueue sends to Experience API
-> API returns OptimizationData { profile, selectedOptimizations, changes }
-> State interceptors run in insertion order
-> batch(() => { profileSignal, selectedOptimizationsSignal, changesSignal }) updates changed values
-> Exposed observables emit deep-cloned snapshots; flag observables re-resolve from changes
Batching the writes means downstream effects and computed signals see a consistent snapshot. There
is no intermediate state where profile has updated but selectedOptimizations has not.
State interceptors run before Core writes Experience API response data to observable state. Runtime state remains memory-backed: platform storage is read during initialization to seed continuity, and live reads use the SDK's in-memory signals. Platform SDKs use the interceptor hook to mirror the same response snapshot to durable profile-continuity storage before publishing the in-memory state that depends on it.
When durable profile-continuity persistence consent is true, a stateful SDK should not emit a new
states.profile, states.selectedOptimizations, or states.canOptimize value for an Experience
response until the platform storage write has either completed or failed and been handled. This does
not make storage infallible or make storage the source of truth for live state; a failed write can
still leave the SDK running on the response data in memory. It does mean application code and tests
can wait for SDK-derived state instead of adding arbitrary delays before relaunch-sensitive work.
When durable persistence consent is false or unset, profile-continuity values are not written for
the response. The SDK may still publish in-memory state for allowed events, but that state should be
treated as session-only.
The send path checks hasConsent(methodName) inside sendExperienceEvent and sendInsightsEvent
before a queue accepts the event. When consent is false or undefined and the event type is not
allow-listed, the event is blocked, a BlockedEvent record is written to the blockedEvent signal,
and the configured onEventBlocked callback is invoked.
Core defaults allowedEventTypes to [], so Core itself fails closed before consent. Platform SDKs
set runtime-specific defaults for event types that are valid in that runtime. For example, Web and
Node use identify/page, while mobile runtimes use identify/screen. This list can be changed
via the allowedEventTypes configuration option.
Calling consent(true) unblocks all gated events going forward and grants durable
profile-continuity persistence consent. Calling consent({ events: true, persistence: false })
allows event emission while keeping profile continuity session-only. Blocked events are not replayed
after consent is granted.
states surfaceObservable contractEvery entry on sdk.states is an Observable<T> with three members:
current - Returns a deep-cloned snapshot of the current signal value. Reading it does not
establish a reactive subscription.subscribe(next) - Registers a callback that is called immediately with the current value and
again whenever the underlying signal changes. Returns a Subscription with an unsubscribe
method.subscribeOnce(next) - Registers a callback that fires exactly once when the first
non-nullish value is available, then automatically unsubscribes. Useful for one-time
initialization that must wait for data to arrive.All values delivered through current, subscribe, and subscribeOnce are deep-cloned before
reaching your code. Mutating a received value does not affect internal signal state.
| Observable | Type | Description |
|---|---|---|
states.consent |
Observable<boolean | undefined> |
Current consent value. |
states.persistenceConsent |
Observable<boolean | undefined> |
Current durable profile-continuity persistence consent value. |
states.profile |
Observable<Profile | undefined> |
Active user profile. |
states.selectedOptimizations |
Observable<SelectedOptimizationArray | undefined> |
Active variant selections. |
states.canOptimize |
Observable<boolean> |
true when variant data is available. |
states.eventStream |
Observable<InsightsEvent | ExperienceEvent | undefined> |
Most recently emitted event. |
states.blockedEventStream |
Observable<BlockedEvent | undefined> |
Most recently blocked event. |
states.previewPanelAttached |
Observable<boolean> |
Whether the preview panel bridge is registered. |
states.previewPanelOpen |
Observable<boolean> |
Whether the preview panel is open. |
states.flag(name) |
Observable<Json> |
Per-flag observable that resolves from changes and tracks flag views via Insights. |
core-sdk exports the raw signals object and the signalFns helper bundle for SDK layers and
first-party preview tooling. Application code must not use those exports to read or write runtime
state directly for several reasons:
states.flag(name) does more than read a value. It emits flag view
events to Insights so flag observations are recorded. Reading the signal directly skips that
reporting.states
surface is the stable, versioned API; signals are not.Use states.* observables in application code. Reserve signals and signalFns for SDK layers
building on top of CoreStateful, for example a framework integration that needs to bridge signals
into a React context or synchronize them with local storage.
Subscribe to states.profile to be notified whenever the active Optimization profile changes. This
is useful for readiness checks, diagnostics, or SDK-adjacent UI that depends on profile
availability.
const subscription = sdk.states.profile.subscribe((profile) => {
setOptimizationProfileReady(profile !== undefined)
})
// Later, when the component unmounts or the SDK is no longer needed:
subscription.unsubscribe()
subscribe emits the current value immediately upon registration, so you do not need a separate
call to read the initial state. Use your application user ID for third-party analytics identity
calls. Do not pass the Optimization profile ID to a vendor identify() call as the known-user ID.
Subscribe to states.selectedOptimizations to respond when the set of active variants changes:
sdk.states.selectedOptimizations.subscribe((variants) => {
if (variants) {
applyLayoutVariant(variants)
}
})
Use states.canOptimize when you only need to know whether variant data exists, without inspecting
the variants themselves:
sdk.states.canOptimize.subscribe((ready) => {
setOptimizationReady(ready)
})
If you need to act only once, for example to set an initial variant before rendering, use
subscribeOnce:
sdk.states.selectedOptimizations.subscribeOnce((variants) => {
renderInitialLayout(variants)
})
subscribeOnce skips null and undefined and delivers the first non-nullish value, then
unsubscribes automatically.
states.flag(name) returns an Observable<Json> that resolves the Custom Flag value from the
internal changes signal. The observable emits when the resolved value changes:
const darkModeSubscription = sdk.states.flag('dark-mode').subscribe((value) => {
document.body.classList.toggle('dark', value === true)
})
states.flag(name).subscribe() suppresses duplicate emitted values using deep equality and emits a
flag view event for each delivered value. states.flag(name).current represents a direct read, so
each current read emits a flag view event. getFlag(name) is nonreactive and deduplicates flag
view events when repeated calls resolve the same value.
If you forward Custom Flag values to a third-party analytics destination, use the same flag read or
render path that your application already owns. Adding a states.flag(name) subscription only for
third-party forwarding also creates an additional Contentful flag-view observation.
Subscribe to states.blockedEventStream to receive details about any event that consent gating
prevented from being sent:
sdk.states.blockedEventStream.subscribe((blocked) => {
if (!blocked) return
console.warn(`${blocked.method} was blocked (reason: ${blocked.reason})`)
})
This is particularly useful during integration testing and consent-flow debugging. Use
blocked.method to see which SDK method was blocked and blocked.reason to confirm that consent
gating blocked it.
You can also handle blocks at construction time using the onEventBlocked config option:
const sdk = new ContentfulOptimization({
clientId: 'my-client-id',
onEventBlocked: (blocked) => {
logger.warn('Optimization event blocked', blocked)
},
})
onEventBlocked and states.blockedEventStream carry the same information. Use
states.blockedEventStream when you need to subscribe and unsubscribe dynamically; use
onEventBlocked when you want a single, always-on handler configured at startup.
states.eventStream emits a snapshot of every event that passes through the SDK, whether it goes to
the Experience API or the Insights API:
sdk.states.eventStream.subscribe((event) => {
if (event) {
devToolsPanel.logEvent(event)
}
})
This is useful for building debugging overlays, integration tests that assert on emitted payloads, or custom telemetry pipelines that operate alongside the SDK's own delivery.
Sticky trackView() calls produce two eventStream records for one semantic view: an Experience
record followed by a paired Insights record. They have distinct messageId values, so analytics
integrations that need one exposure must dedupe by semantic fields such as viewId, componentId,
experienceId, and variantIndex.
React Web and React Native provider roots accept onStatesReady for app-level subscribers that
should be coordinated by the provider instead of by application code polling or waiting for an SDK
instance. This addresses both common timing problems: the SDK state surface might not exist yet when
application code tries to subscribe, or the SDK might already have existed long enough for initial
router, screen, or blocked-event data to be missed. The callback receives only the states surface
and may return a cleanup function:
<OptimizationRoot
clientId="my-client-id"
onStatesReady={(states) => {
const subscription = states.eventStream.subscribe((event) => {
if (event) devToolsPanel.logEvent(event)
})
return () => {
subscription.unsubscribe()
}
}}
>
<App />
</OptimizationRoot>
onStatesReady complements, but does not replace, onEventBlocked. Use onEventBlocked for one
startup callback dedicated to blocked events. Use onStatesReady when a framework app needs a clear
provider-owned place to subscribe to eventStream, blockedEventStream, or other observables as
soon as SDK state is available and before child router, screen, or entry effects run. Each
subscription still immediately emits its current snapshot.
Both framework roots set up provider-owned SDK instances outside render and render no children while
SDK initialization or provider-managed state subscriber setup is pending. React Web uses
layout-effect scheduling for provider-owned browser SDK creation so ready children normally mount
before first visible paint. React Native keeps async effect scheduling because SDK creation depends
on platform storage and device state. When a framework adapter injects an already-created SDK,
children can render immediately unless onStatesReady is provided.
Consent is a precondition for most event emission. Set it with the consent method:
// Grant consent after application policy allows SDK event emission.
sdk.consent(true)
// Allow events but keep durable profile continuity disabled
sdk.consent({ events: true, persistence: false })
// Revoke consent
sdk.consent(false)
React to the current consent value through states.consent:
sdk.states.consent.subscribe((value) => {
updateConsentBadge(value)
})
The SDK does not provide a consent UI. Consent policy, including when to ask, what to display, and
how to store any user choice, belongs to your application. If application policy permits SDK
activity by default, stateful SDKs can start from defaults.consent: true instead of rendering an
end-user consent UI. The SDK exposes consent() to receive runtime changes and states.consent to
let your application reflect them. Use states.persistenceConsent when the application needs to
reflect whether durable profile-continuity storage is allowed separately from event emission.
reset() clears profile, selectedOptimizations, changes, event, and blockedEvent in a
single batch. Use it when a user signs out and you want to discard all identity and optimization
state:
function handleSignOut() {
sdk.reset()
// sdk.states.profile.current is now undefined
// sdk.states.selectedOptimizations.current is now undefined
}
reset() does not affect consent. Consent state survives a reset and persists across sessions in
SDKs that write to storage (for example, the Web SDK, which stores consent in localStorage).
CoreBase exposes an interceptors.event InterceptorManager that lets you transform or inspect
every event before it is validated and sent. An interceptor is a function that receives a
Readonly<InsightsEvent | ExperienceEvent> and returns a (possibly async) event of the same type:
const interceptorId = sdk.interceptors.event.add(async (event) => {
// Return a new object. Do not mutate the readonly input.
return {
...event,
context: {
...event.context,
app: { version: APP_VERSION },
},
}
})
// Remove the interceptor when it is no longer needed:
sdk.interceptors.event.remove(interceptorId)
Interceptors run in the order they were added. Each interceptor receives the output of the previous one. The chain is snapshotted at invocation time, so adding or removing interceptors while an event is in flight does not affect that event.
The value parameter is typed Readonly<T>. Return a new or safely updated object rather than
mutating the input. Mutating the parameter produces undefined behavior because the input is shared
across the chain.
interceptors.state applies the same mechanism to OptimizationData responses from the Experience
API before they are written to the profile, selectedOptimizations, and changes signals:
sdk.interceptors.state.add((data) => {
// Normalize all trait keys to lowercase
const traits = Object.fromEntries(
Object.entries(data.profile?.traits ?? {}).map(([k, v]) => [k.toLowerCase(), v]),
)
return {
...data,
profile: data.profile ? { ...data.profile, traits } : data.profile,
}
})
State interceptors run after the API responds but before the batch signal write, so every subscriber sees the transformed values.
Interceptors are the right tool when you need to:
Interceptors are not appropriate for conditionally suppressing events based on business logic. If you need to decide whether an event fires at all, that decision belongs in application code before the SDK method is called.
core-sdk exports the signals bundle, which gives you a reference to every internal signal.
Writing to a signal directly, such as signals.profile.value = newProfile, bypasses every layer
that makes state changes safe and coherent:
false.batch() call can trigger intermediate
reactive states that subscribers see before all changes are applied.If you find yourself wanting to write to a signal directly, use one of the patterns above instead: a
method call, a state interceptor, or a defaults configuration value.
The signals and signalFns exports are intended for SDK layers that extend CoreStateful (such
as the Web SDK or the React Native SDK) and for first-party preview tooling. They are not part of
the application consumer API.