State management
How the React SDK stays in sync with the ArlopassClient
The challenge
ArlopassClient is a plain TypeScript class. It has getters like .state, .sessionId, and .selectedProvider — but they're not reactive. Nothing in the Web SDK knows about React, and that's intentional. The SDK is framework-agnostic. So how does the React SDK keep components in sync with an external, non-reactive object?
// ArlopassClient has internal state — but it's just getters, not reactive.
import { ArlopassClient } from "@arlopass/web-sdk";
const client = new ArlopassClient({ transport: window.arlopass });
await client.connect({ appId: "my-app" });
client.state; // "connected" — a plain getter
client.sessionId; // "uuid-1234" — a plain getter
client.selectedProvider; // null — a plain getter
// React components won't re-render when these change.
// There's no .onChange() callback, no EventEmitter, no observable.
// The SDK is deliberately framework-agnostic.
ClientStore
The answer is ClientStore. It wraps a ArlopassClient and maintains a snapshot — a plain object that represents the client's current state at a point in time. When the store detects a change, it creates a new snapshot object and notifies subscribers. When nothing changes, it keeps the same object reference.
// ClientStore wraps ArlopassClient and maintains a reactive snapshot.
class ClientStore {
#client: ArlopassClient;
#snapshot: ClientSnapshot;
#subscriptions = new Subscriptions();
constructor(client: ArlopassClient) {
this.#client = client;
this.#snapshot = createInitialSnapshot();
this.#startHeartbeat(); // 500ms safety-net polling
}
// React's useSyncExternalStore calls these two:
getSnapshot(): ClientSnapshot {
return this.#snapshot;
}
subscribe(listener: () => void): () => void {
return this.#subscriptions.subscribe(listener);
}
// Called after every SDK operation
refreshSnapshot(): void {
const next = buildSnapshot({
state: this.#client.state,
sessionId: this.#client.sessionId ?? null,
selectedProvider: this.#client.selectedProvider ?? null,
providers: this.#providers,
error: this.#error,
});
// Only notify if something actually changed
if (!snapshotsEqual(this.#snapshot, next)) {
this.#snapshot = next;
this.#subscriptions.notify();
}
}
}
useSyncExternalStore
React 18 introduced useSyncExternalStore specifically for this pattern — reading from an external store that isn't managed by React. It guarantees tear-free reads (no partial state) and works correctly with concurrent features like Suspense and transitions. Every Arlopass hook uses it under the hood.
// React 18's useSyncExternalStore — the bridge between external and React state.
import { useSyncExternalStore } from "react";
// Full snapshot — used internally
function useStoreSnapshot(): ClientSnapshot {
const { store } = useArlopassContext();
return useSyncExternalStore(
(cb) => store.subscribe(cb),
() => store.getSnapshot(),
() => store.getSnapshot(), // server snapshot (same for SSR safety)
);
}
// Selective subscription — each hook picks its slice
function useStoreSelector<T>(selector: (snap: ClientSnapshot) => T): T {
const { store } = useArlopassContext();
return useSyncExternalStore(
(cb) => store.subscribe(cb),
() => selector(store.getSnapshot()),
() => selector(store.getSnapshot()),
);
}
// So useConnection() only re-renders on state/sessionId changes,
// useProviders() only re-renders on provider list changes, etc.
Snapshot identity
The key to avoiding unnecessary re-renders is snapshot identity. The store compares the current snapshot to the next one field-by-field. If nothing changed, it keeps the old object. Since useSyncExternalStore uses Object.is to compare, same reference means no re-render.
// A new snapshot object is created ONLY when values actually differ.
function snapshotsEqual(a: ClientSnapshot, b: ClientSnapshot): boolean {
return (
a.state === b.state &&
a.sessionId === b.sessionId &&
a.selectedProvider === b.selectedProvider &&
a.providers === b.providers && // referential equality — same array
a.error === b.error
);
}
// If nothing changed, the old snapshot object is kept.
// useSyncExternalStore compares by reference (Object.is).
// Same reference = no re-render. This is what prevents
// the 500ms heartbeat from causing unnecessary re-renders.
Primary sync and safety-net polling
The store uses two complementary strategies. The primary strategy is wrap-and-refresh: every SDK operation goes through the store, which calls refreshSnapshot() after completion. The safety net is a 500ms heartbeat that catches changes the store didn't initiate — like the extension unloading or the bridge dropping. The snapshot equality check means the heartbeat is effectively free when nothing has changed.
// Two complementary sync strategies keep the UI accurate.
// 1. PRIMARY: Wrap-and-refresh
// Every SDK operation goes through the store, which calls
// refreshSnapshot() after the operation completes.
//
// connect() → client.connect() → refreshSnapshot()
// selectProvider() → client.selectProvider() → refreshSnapshot()
// stream() → each token → refreshSnapshot()
// 2. SAFETY NET: 500ms heartbeat polling
// Some state changes happen outside the store's control:
// - Extension unloads or crashes
// - Bridge connection drops
// - Another tab disconnects the same session
//
// The heartbeat catches these by periodically reading
// client.state and comparing to the last snapshot.
// The snapshot equality check prevents spurious re-renders.
const HEARTBEAT_INTERVAL_MS = 500;
this.#heartbeatId = setInterval(() => {
this.refreshSnapshot(); // No-op if nothing changed
}, HEARTBEAT_INTERVAL_MS);
Selective subscriptions
Each hook subscribes to only the slice of state it needs via useStoreSelector. useConnection() cares about state and sessionId. useProviders() cares about the provider list. A change to the provider list doesn't re-render components that only use useConnection().
Streaming optimization
During streaming, tokens can arrive hundreds of times per second. Re-rendering on every token would destroy performance. The store uses requestAnimationFrame with setTimeout microbatching — tokens accumulate, and the store refreshes at most ~60 times per second. Each render sees the latest accumulated content, not individual tokens.
// Streaming tokens arrive very fast — potentially hundreds per second.
// Re-rendering on every token would tank performance.
//
// The store uses requestAnimationFrame + setTimeout microbatching:
// 1. Token arrives → schedule a RAF callback (if not already scheduled)
// 2. RAF fires → batch all accumulated tokens → refreshSnapshot() once
// 3. Result: ~60 refreshes/sec max, regardless of token rate
//
// This means the UI stays smooth during streaming, and each render
// has the latest accumulated content — not one render per token.