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.