Skip to content

Architecture

The whole SDK is one provider that owns one runtime. Understanding that sentence explains every design choice below.

<ConsoleProvider> mounts a runtime and provides it through a single React context. The runtime is the bag of provider-owned instances — the stores that hold reactive state, and the engines that talk to hardware. Every hook resolves that context and reads a store or calls an engine method. There is no global to import, no service to locate, and no instance to construct yourself.

The runtime is built once, in a useState initializer:

// Inside the provider (illustrative — you never write this):
const [runtime] = useState(() => createRuntime(config));

useState(() => …) runs the initializer exactly once per mount and never again on re-render. That is the entire lifecycle: mount builds the runtime, unmount disposes it. There is no init(), no ready flag to poll, no reset().

createConsoleConfig() is inert — it validates and freezes its input and does zero I/O. No radio spins up, no storage is read, nothing connects. All of that happens inside createRuntime, when the provider mounts. This split is what lets you define a config at module scope, pass it to a test, or diff two configs without any side effect firing.

The provider treats the identity of the config object as its key. Passing a new ConsoleConfig object means: run the current runtime’s ordered teardown (graceful device detach → session abandonment → transport dispose), then build a fresh runtime from the new config. There are no setters and no partial reconfiguration — a config change is a clean recreate, so there is no in-between state to reason about.

Internally this is just React: the runtime lives in a keyed host component whose key is derived from the config identity. A new key remounts the host, which disposes the old runtime in its effect cleanup and constructs a new one in its initializer. Reconfiguration and teardown reuse the exact same machinery React already provides.

The practical consequence: build your config once (module scope) or memoize it, so you do not remount the runtime on every render. See Provider setup.

Reactive state lives in provider-owned stores. Each store is a small useSyncExternalStore-compatible object — a snapshot plus a subscribe function. Hooks read stores through a selector, so a component re-renders only when the value it selected changes. Per-device state is keyed by DeviceId in maps and arrays; there is deliberately no “current device” field anywhere in the runtime — that absence is what makes the multi-console model structural rather than a convention (see The fleet model).

Hardware events never reach your components as events. The transport delegates BLE notifications and adapter changes into stores at the lowest possible boundary, and consumers see the result only as reactive hook return values — there is no addListener, no onDisconnect, no event emitter in the public or internal surface.

No singletons — and the one declared exception

Section titled “No singletons — and the one declared exception”

There is no getInstance(), no module-level mutable state, and no service locator. Everything is a provider-owned instance created in the runtime initializer, so two providers in one tree are fully isolated and a test needs no global reset.

The one irreducible exception is react-native-ble-plx’s BleManager: the native Bluetooth manager is physically one-per-app. The SDK wraps exactly one provider-owned instance of it behind the transport seam, so consumers never see it. This exception is explicit, wrapped, and invisible — it does not leak a singleton into your code.

The SDK exposes four import paths, and nothing from a subpath is re-exported at the root:

  • @rogue/console-sdk — the provider, hooks, and public types.
  • @rogue/console-sdk/testing — the simulated transport, fixtures, and test setup (ships in production, costs nothing unimported).
  • @rogue/console-sdk/units — pure, platform-neutral formatting.
  • @rogue/console-sdk/storage/async-storage — the async-storage adapter.

Every export is named (no default exports), so a bundler drops what you do not import — the test tooling never ships in your app bundle unless you reach for it.

The single place hardware exists is the transport. The real BLE transport, the simulated transport, and any future swap all implement the same public contract, and the SDK injects one via config as a factory. Because the seam is public, it is simultaneously the testing seam (swap in the simulated transport) and the future-migration seam (swap the BLE library) — the same mechanism, no forked code paths. This is why “mocking” is a config swap, not a mode flag (see Testing & mocking).