@statekit/connector
v0.5.4-dev
Published
Downloads
1,218
Readme
@statekit/connector
Field tracking and shared runtime for view-library adapters.
@statekit/react already uses this package. Use it directly when building an adapter for another framework, renderer, or platform. It is not another state manager, and most application code won't import it.
Work in progress.
What it does
The connector records which machine fields a view reads. After an event, the adapter compares only those fields and can skip an unrelated render.
view reads data.user.name
|
v
connector records user.name
|
v
machine publishes new data
|
v
did user.name change? -- no --> skip render
|
yes
|
v
renderInstall
npm i @statekit/core @statekit/connectorTrack data reads
import { createMachine } from "@statekit/core"
import { createMachineProxy, SubscriptionManager } from "@statekit/connector"
const machine = createMachine({
data: {
user: { name: "Ada" },
theme: "light",
},
events: {
rename: ({ draft }, name: string) => {
draft.user.name = name
},
setTheme: ({ draft }, theme: string) => {
draft.theme = theme
},
},
})
const machineRef = { current: machine }
const subscriptions = new SubscriptionManager()
let readsState = false
const proxyRef = createMachineProxy(
subscriptions,
machineRef,
() => void (readsState = true)
)
proxyRef.current.data.user.name
let before = machine.data
machine.event.setTheme("dark")
subscriptions.checkIsSubscribedChanged(before, machine.data) // false
before = machine.data
machine.event.rename("Grace")
subscriptions.checkIsSubscribedChanged(before, machine.data) // trueOnly user.name was read through the proxy. Changing theme does not affect that consumer; changing user.name does.
Track state reads
Reading the proxied state calls the third argument passed to createMachineProxy:
proxyRef.current.state
readsState // trueThe adapter can then compare the previous and current machine state alongside data changes.
Objects, arrays, and selectors
Nested objects and arrays keep their normal behavior. Reads through Object.keys, Object.values, object spread, iteration, and array methods are tracked too.
Selectors called through proxyRef.current.select run against the proxied machine, so they record the fields they read. A consumer that only calls select.isPositive() changes when count changes, not when an unrelated label does.
Date, Map, Set, RegExp, Promise, and similar built-ins are returned without a proxy, because their methods need the real object. Reading one tracks its path, not its contents.
How changes are compared
Each tracked path is compared with Object.is. A tracked value replaced by an equal copy, such as a new Date with the same time, counts as a change. A value mutated in place behind the same reference does not. Keep data that views read plain.
The data root is always an object or array; core guarantees it. Values inside it can become null, a primitive, or a different shape between snapshots, and the comparison handles that.
Shared runtime
Adapters share one StateKit runtime per JavaScript realm. Read it, or create it if it does not exist yet:
import { getStatekit, initStatekit } from "@statekit/connector"
const statekit = getStatekit() ?? initStatekit()It is stored on globalThis, so the same calls work in browsers, Node, and workers. Don't hide a private runtime inside a framework context; adapters that do can't work together. Field tracking itself does not require this runtime.
getIsBrowser, getIsNode, and getIsWebWorker are exported for code that depends on the environment.
Building an adapter
The core machine is the source of truth. Core owns the data, events, selectors, states, transitions, and async behavior. The adapter watches the machine and asks its framework to update at the right time. It does not copy data into a second store or recreate core behavior.
One tracker per consumer
Give every independently rendered consumer its own SubscriptionManager and proxy. If one consumer reads user.name and another reads cart.total, a shared tracker wakes both for both changes.
Wake the framework
When the machine notifies, compare what this consumer last saw with the machine now:
const dataChanged = subscriptions.checkIsSubscribedChanged(
previousData,
machineRef.current.data
)
const stateChanged =
readsState && !Object.is(previousState, machineRef.current.state)
previousData = machineRef.current.data
previousState = machineRef.current.state
if (dataChanged || stateChanged) notifyFrameworkConsumer()Save the snapshots after every notification, not only when you render. Otherwise the previous value falls behind and later comparisons are wrong.
notifyFrameworkConsumer is the framework-specific part: update a signal, invalidate a view, or schedule a render with the mechanism the framework recommends.
Fit the framework's lifecycle
- Keep the proxy and subscription manager stable across renders.
- Remove every listener on unmount or disposal.
- Survive development remounts and strict modes without duplicate listeners.
- Run the machine's initializer once, in the browser, at the first client-side use.
Hand back the core API
Users should get the same machine back, with its types intact: data, event, select, state, STATE, setState, setData, and subscribeEvents. Add the ergonomics your framework needs around it, such as hooks, signals, or plugins, without renaming or reinterpreting it.
Share machines between adapters
A page can mix frameworks. When two adapters use the same machine, an event called from either side, or from plain JavaScript, must update both.
React consumer ──calls increment──┐
v
sharedCounter
|
update fans out to adapters
┌───────┴───────┐
v v
React consumer other consumer- Keep one machine, not one copy per adapter.
- Call core events directly instead of wrapping them in framework events.
- Don't bundle a private copy of core.
- Mounting or unmounting one adapter must not disconnect another.
Before you ship an adapter
Test at least:
- one consumer and one primitive field;
- nested objects and arrays;
- object keys being added and removed;
- selectors;
- state-only updates, and consumers that never read state skipping them;
- sync events and every async phase: start, resolve, and reject;
- two consumers with separate dependencies;
- mount, unmount, and remount, including strict mode;
- server rendering and hydration;
- an event called outside the framework;
- two adapters sharing one machine, with updates in both directions.
For the last test, a small reference adapter with its own lifecycle is enough.
@statekit/react is the reference implementation. Borrow its plumbing, not its React-shaped API.
Do
- Keep one
SubscriptionManagerfor each consumer. - Read from
proxyRef.currentwhile collecting fields. - Keep
machineRef.currentpointed at the latest machine. - Compare the previous data snapshot with the latest one after every notification.
- Share the global runtime from
getStatekit() ?? initStatekit().
Don't
- Don't use this package directly in a normal React app; use
@statekit/react. - Don't share one
SubscriptionManagerbetween unrelated consumers. - Don't copy machine data into a framework store.
- Don't turn field paths into strings; symbols and array indexes must keep their identity.
- Don't save an old
proxyRef.currentvalue and expect it to follow a replaced machine ref.
Development
From the node directory:
pnpm --filter @statekit/connector test -- --runInBand
pnpm --filter @statekit/connector build