Architecture
The whole SDK is one provider that owns one runtime. Understanding that sentence explains every design choice below.
One provider, one runtime
Section titled “One provider, one runtime”<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().
The inert config, applied lazily
Section titled “The inert config, applied lazily”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.
Config identity is the truth
Section titled “Config identity is the truth”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.
Where state lives
Section titled “Where state lives”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.
Tree-shakeable by construction
Section titled “Tree-shakeable by construction”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 transport seam
Section titled “The transport seam”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).