@clickstreamhq/reactis a developer preview, published to npm at 0.1.0-alpha.4. Every API in this tutorial is the real, current surface — the same one the package's own tests run against — but expect it to evolve before a stable release, and pin your version. Of the ClickStream packages, only@clickstreamhq/sdk(1.4.0) is stable today.
Why React Personalization Hooks Beat Ad-Hoc Fetches
Personalization in React is a state problem before it's a data problem. The underlying @clickstreamhq/signals library is framework-agnostic: you call configure({ apiKey }) once, then getVisitor() returns the visitor's context — bot classification, identity status, and behavioral scores (we cover that surface in Getting Started with the Signals API). But wiring that into components by hand means every component reinvents the same lifecycle: fetch on mount, cancel on unmount, poll for updates, decide what to render before the first answer arrives.
@clickstreamhq/react is that lifecycle, packaged. It's intentionally thin — all side-effectful work stays in the signals library, and the React layer's job is lifecycle management plus a stable state shape. The surface is one provider and four hooks:
<ClickStreamProvider>— configures the signals client once, near the rootuseVisitor()— reactive visitor context and scoresuseIdentify()— a callback to identify the current visitoruseTrack()— a callback to fire a custom eventuseClickStream()— low-level state access (configured+error)
One boundary to state plainly before any code: this package reads Signals; it does not install the tracking pixel. Keep the pixel installed with a static script tag (see the install guide) or @clickstreamhq/sdk, so the page has the _cs_vid visitor cookie, the _cs_sid session cookie, and the window.clickstream bridge that useIdentify and useTrack forward to.
Setup: ClickStreamProvider Once, Near the Root
npm install @clickstreamhq/sdk @clickstreamhq/react @clickstreamhq/signals
Then mount the provider. In a Next.js App Router project, that's a client component wrapping your tree:
'use client';
import { useEffect } from 'react';
import { installClickstreamPixel } from '@clickstreamhq/sdk';
import { ClickStreamProvider } from '@clickstreamhq/react';
export function Providers({ children }: { children: React.ReactNode }) {
useEffect(() => {
installClickstreamPixel({
apiKey: process.env.NEXT_PUBLIC_CLICKSTREAM_KEY!,
endpoint: 'https://t.example.com',
replay: true,
});
}, []);
return (
<ClickStreamProvider
apiKey={process.env.NEXT_PUBLIC_CLICKSTREAM_KEY!}
endpoint="https://t.example.com"
>
{children}
</ClickStreamProvider>
);
}
The provider calls @clickstreamhq/signals.configure() with your config on mount and resets it on unmount — so you never call configure() yourself in components, and the signals invariant (configure before any read) holds by construction. Two props matter most: apiKey (your cs_live_* browser key) and endpoint, which should be your verified first-party tracking domain (like https://t.example.com) so reads stay same-origin with the SDK's cookies. There's also pollIntervalMs — the update cadence for hooks, defaulting to 2,000 ms with a floor of 1,000.
If configure() throws — say, an invalid key — the provider captures the error on its state and renders children anyway. Downstream hooks degrade gracefully instead of crashing the app. That's the first appearance of a theme that runs through this whole package.
useVisitor() Returns { ctx, loading, error } — Never the Visitor
The most common mistake with reactive visitor data is treating it as always-present. useVisitor() makes that impossible: it never returns the visitor directly. The return shape is always { ctx, loading, error }, where ctx is null until the first poll lands. So the idiom is:
import { useVisitor } from '@clickstreamhq/react';
export function PricingBanner() {
const { ctx, loading, error } = useVisitor();
if (loading) return null; // first tick hasn't landed yet
if (error || !ctx) return <DefaultBanner />; // fail open
if (ctx.bot.isBot) return <DefaultBanner />; // don't personalize for bots
if (ctx.scores.intent >= 70) return <HighIntentOffer />;
if (ctx.scores.frustration >= 60) return <SupportPromo />;
return <DefaultBanner />;
}
Three lifecycle details worth knowing:
loadingflips once. It'struefor the first tick only; subsequent polls updatectxin place without bouncing the UI back into a loading state.- There's a watchdog. If no visitor context is observed within 15 seconds — SDK not running, cookie missing — the hook stops reporting
loadingand surfaces anerrorinstead, so you're never stuck rendering a skeleton forever. - The field paths are exact. Intent is
ctx.scores.intent(0–100), frustration isctx.scores.frustration(0–100), and the bot flag isctx.bot.isBot. Thescoressnapshot is a curated subset of the collector's 26-model scoring pipeline, whose per-event pass is held to p95 under 3 ms as a CI-enforced benchmark.
Gating Components on Intent and Frustration
The thresholds above aren't arbitrary. 70 is DEFAULT_HIGH_INTENT_THRESHOLD and 60 is DEFAULT_FRUSTRATED_THRESHOLD — the same bands the ClickStream dashboard uses, so a page-level gate lines up with the badge an operator sees. Rather than hand-rolling comparisons, pass ctx to the helper predicates from @clickstreamhq/signals, which are synchronous when given a context:
import { useVisitor } from '@clickstreamhq/react';
import { isBot, isHighIntent, isFrustrated } from '@clickstreamhq/signals';
export function OfferGate({ children }: { children: React.ReactNode }) {
const { ctx } = useVisitor();
if (!ctx || isBot(ctx)) return null;
if (isFrustrated(ctx)) return <HelpOffer />; // ctx.scores.frustration >= 60
if (isHighIntent(ctx)) return <>{children}</>; // ctx.scores.intent >= 70
return null;
}
Both helpers accept a custom threshold as a second argument if your funnel needs different bands. For what the scores mean behaviorally — and why intent moves through four stages rather than jumping straight to 100 — see Scoring Intent, Frustration, and Engagement in Real Time. And if your gating logic is simple enough to be declarative, the effects engine can replace the component entirely with a one-line rule.
Realtime Personalization Hooks on Scale+
useVisitor() polls by default, which is cheap and fail-open. For surfaces that need instant page manipulation — a banner that should appear the moment conversion readiness crosses a line — pass { realtime: true }:
'use client';
import { useVisitor } from '@clickstreamhq/react';
export function HighIntentBanner() {
const { ctx } = useVisitor({ realtime: true });
if (!ctx || ctx.stale || ctx.bot.isBot || ctx.scores.conversionReadiness < 70) return null;
return <button data-signal-effect="high_intent_action">Talk to us</button>;
}
The realtime path rides the cost-bounded per-visitor WebSocket stream, which is available on Scale and above and reserves Signals Coverage units when it opens. The important property: your component code is identical either way. When the stream cannot connect, is capped, billing coverage is exhausted, or the handshake is rejected, the hook falls back to polling automatically. Note the ctx.stale check — realtime surfaces should decline to act on a snapshot the client or server has marked stale. Use realtime sparingly, on the few components that genuinely need it; polling is the right default everywhere else.
useIdentify and useTrack: Acting on What You Read
Reading context is half the loop; the other half is feeding events and identity back. Both hooks return stable callbacks meant for event handlers:
import { useVisitor, useTrack, useIdentify } from '@clickstreamhq/react';
export function HelpButton() {
const { ctx } = useVisitor();
const track = useTrack();
const identify = useIdentify();
const human = ctx && !ctx.bot.isBot && ctx.behavioralClass === 'human';
return (
<button
onClick={async () => {
track({ name: 'help_opened', category: 'interaction' });
if (human) await identify('user@example.com');
}}
>
{human && (ctx.scores.frustration ?? 0) >= 60 ? 'Get help now' : 'Help'}
</button>
);
}
Notice the double gate before identifying: !ctx.bot.isBot catches network-level bot classification, and ctx.behavioralClass === 'human' catches automation that evaded it behaviorally. The two axes are independent by design. (What happens after identify() — and how an anonymous visitor becomes a CRM contact compliantly — is its own topic: From Anonymous Visitor to CRM Contact.)
One honest constraint on useTrack(): the ClickStream custom-event pipeline delivers exactly five fields — name, category, action, label (strings) and value (number). There is no free-form metadata slot end-to-end. If you pass a metadata object, keys named category/action/label/value are promoted into the matching top-level field when it's unset; every other key is dropped, with a development-mode console warning so you find out early. Need arbitrary data? Fold it into label yourself, for example as a compact JSON string.
Failure behavior differs deliberately between the two: identify() throws if the SDK bridge isn't on the page (you want to know your identity pipeline is broken), while track() degrades silently with a console warning — a missing analytics script should never surface an error to a user who just clicked a button.
Loading, Error, and Degraded-Coverage UX
The fourth hook, useClickStream(), exposes the provider's own state — { configured, error } — for the rare component that needs to know whether Signals is up at all:
import { useClickStream } from '@clickstreamhq/react';
export function SignalsGate({ children }: { children: React.ReactNode }) {
const { configured, error } = useClickStream();
// Signals down or misconfigured? The page still works — just unpersonalized.
if (!configured || error) return null;
return <>{children}</>;
}
Across all four hooks, the UX rules that fall out of the design are consistent:
- Null
ctxmeans default UI, not spinner. Personalization is an enhancement; the unpersonalized page is the baseline, not an error state. - Gate on
loadingonly for layout stability — and remember it flips exactly once. After the first tick, updates arrive in place. - Check
ctx.staleandctx.pendingbefore high-stakes moves. When Signals Coverage for the billing period is exhausted, the server returns a placeholder context with all scores zeroed,stale: true,pending: true, andbehavioralClass: 'suspicious'— so threshold gates and human-only gates fail closed on their own, and explicit checks cover anything custom. - Billing never breaks rendering. Realtime downgrades to polling, polling reuses safe snapshots where possible, and rate limits land as null contexts, not exceptions — the same fail-open billing discipline the rest of the platform follows. For the full defensive playbook, see Fail-Open Personalization Patterns.
What Developer Preview Means Here
To be concrete about the alpha framing: @clickstreamhq/react ships at 0.1.0-alpha.4, alongside @clickstreamhq/signals in alpha. The provider-plus-four-hooks surface documented above is what's published today, and it's what the package's tests exercise. Alpha status means the API can change between releases — pin your version. It does not mean the data path is experimental: the hooks read the same documented Signals snapshot endpoint the stable browser stack uses, and if you later render on the server, the Next.js adapter resolves the same VisitorContext before paint.
The Bottom Line
React personalization hooks succeed or fail on their defaults, and this package's defaults are opinionated in the right direction:
- One provider, which owns
configure()so components never race it - One shape,
{ ctx, loading, error }— a nullctxis a legitimate, renderable state - Shared thresholds,
isHighIntentat 70 andisFrustratedat 60, matching the dashboard - Opt-in cost, polling by default,
{ realtime: true }only where instant updates earn their Scale+ stream
A personalization hook that can crash your page personalizes nothing. The whole API is arranged so the worst case is the page you already had.