Using it without React
This page is mostly about campaign islands (streams, StreamText’s
obligations, the choreographer). Reading it for fragments only? Skip to
Fragments without a framework.
createClient is the whole story there.
@sparkletree/core has no framework dependency. It speaks the network protocol,
runs the same copy behaviour, and hands you state to render however you like.
@sparkletree/react is a binding over it, not a superset.
npm install @sparkletree/coreRender one block of copy
Section titled “Render one block of copy”import { openStream, decideChrome } from "@sparkletree/core";
const handle = openStream({ target: { apiBase: "https://api.example.com", organizationId: "org_123", campaignId: "camp_123", island: "hero", context: { deviceType: "desktop", language: "en" }, }, initial: { headline: "Software that meets the moment" }, initialIsOnScreen: false, onState: (state) => { headlineEl.textContent = state.fields.headline.text; },});
const final = await handle.finished; // never rejectshandle.close(); // idempotent, safe to call anywayinitial is your fallback copy and is required in practice: it is what
renders instead of anything adapted. At this level the hold arms as the
request opens. @sparkletree/react arms it earlier, at component mount,
and passes the remaining budget through holdMs, so one budget spans the
whole wait. The fields go blank until the stream delivers, or until the
budget hands your copy back: 2.5 seconds or holdMs, restarted once if the
stream proves it is alive. Set initialIsOnScreen: true when that copy is
already visible; the block then never blanks it and never types over it.
Deltas buffer off-canvas and the copy changes once, composed, at *-done.
Reserve the space yourself while it holds. The choreographer is DOM-pure:
it reports what the hold pulled off the canvas in state.held (per field, the
copy that was actually painted) and leaves the drawing to you. Render that
string hidden while field.text is "", or your section collapses for the
hold’s duration and the page shifts when copy lands. This is the one thing
StreamText does for you in React:
const reserve = state.held?.headline;if (state.fields.headline.text === "" && reserve) { headlineEl.textContent = reserve; headlineEl.style.visibility = "hidden";} else { headlineEl.textContent = state.fields.headline.text; headlineEl.style.visibility = "";}state.held is null when no hold armed this page view.
openStream returns { choreographer, finished, close }, where
choreographer is the object holding the current copy state. Subscribe with
onState, or with handle.choreographer.subscribe(fn).
Decide the disclosure label per field, never per stream
Section titled “Decide the disclosure label per field, never per stream”If you show a visible disclosure mark, decide it per field:
const { marked } = decideChrome(state.fields.headline.source, prefersStandard);Use field.source, not state.contentSource. The stream-level value is the
strongest source seen anywhere in the response, and responses really are
mixed: a generated headline can sit beside a body served from your own copy.
Labelling every field by the stream-level value stamps “adapted for you” on text
your copywriter wrote, which is the failure this design treats as worse than a
missing label.
Read the visitor’s preference with viewerPrefersStandard() and write it with
setViewerPrefersStandard(value).
Count impressions yourself
Section titled “Count impressions yourself”The request tells the server the client owns the count, so if you do not record one, nothing does.
import { ImpressionRecorder, observeVisibility } from "@sparkletree/core";
const recorder = new ImpressionRecorder({ apiBase, organizationId, campaignId, variantId: final.variantId ?? undefined,});
observeVisibility(element, () => { recorder.record({ contentSource: final.contentSource, chromeMarked: marked, degraded: final.degraded, island: "hero", });});observeVisibility fires when the element has been 50% visible for 300ms and
returns a cleanup function. ImpressionRecorder fires at most once. Wait until
the copy has settled before recording, so the report describes what was read.
Send clicks and conversions with track(context, "click", { … }).
Apply campaign colours
Section titled “Apply campaign colours”import { applyTheme, themeStyle } from "@sparkletree/core";
const restore = applyTheme(element, state.theme); // element first, then themeconst style = themeStyle(state.theme); // or as a style objectapplyTheme returns a function that undoes it.
Fragments without a framework
Section titled “Fragments without a framework”import { createClient } from "@sparkletree/core";
const client = createClient({ publishableKey: KEY, mintToken });const { fields, warmth } = await client.fragments({ context: { page: "pricing", intent: "signup" }, fields: { cta: { kind: "cta", fallback: "Start your free trial" } },});fragments() resolves fast and never rejects. Same rule as above: render a
disclosure label for any field whose source is not static.
What you own here that React does for you
Section titled “What you own here that React does for you”- Context collection. Time of day, device, language and referring origin are
gathered by the React provider; here you build the
contextobject yourself, with exactly the signals you want in it. - The swap rule for buttons.
useFragmentsrefuses to relabel actaorlabelonce your fallback is on screen.client.fragments()hands you the text and leaves the judgement to you. - The disclosure label. Opt-in on every path; render it from
field.source(React exportsInlineMarkfor the same job). - The first-paint fetch, if you want one.
fetchContentis exported for it, and nothing calls it for you. NeitheropenStreamnor the React path ever fetches/content. Your fallback is the whole first-paint story unless you fetch it yourself.
Watch for a protocol mismatch
Section titled “Watch for a protocol mismatch”openStream takes onProtocolUnsupported({ requested, supported }), called when
the server cannot speak the version this build asked for. Your first paint
already stands by the time it fires; log it once, and do not retry. The React
hook does not expose this.