@statekit/core
v0.10.3-dev
Published
Readme
@statekit/core
Typed state managers that can run in a component, a server, a worker, or a normal function.
Work in progress.
Install
npm i @statekit/coreQuick start
import { createMachine } from "@statekit/core"
const counterMachine = createMachine({
data: { count: 0 },
events: {
increment: ({ draft }, by = 1) => {
draft.count += by
},
},
selectors: {
isPositive: ({ data }) => data.count > 0,
},
})
counterMachine.event.increment(2)
counterMachine.data.count // 2
counterMachine.select.isPositive() // truecounterMachine.data is the latest data snapshot. Events receive a draft; change that draft to publish the next snapshot. Selectors provide named reads without changing data.
Call events anywhere
A machine is a normal object. It does not need a component or hook.
counterMachine.event.increment()
counterMachine.data.count // 3
setTimeout(counterMachine.event.increment, 1000)Read machine.data again when you need the latest snapshot. An older snapshot stays unchanged:
const before = counterMachine.data
counterMachine.event.increment()
const after = counterMachine.data
before.count // 3
after.count // 4Async events
Async events publish changes made before the first await immediately, then publish later changes when the promise settles.
const fileImportMachine = createMachine({
states: ["idle", "importing", "ready", "failed"],
data: {
fileName: "",
contents: "",
},
events: {
importFile: async ({ draft, setState, S }, file: File) => {
setState(S.importing)
try {
const contents = await file.text()
draft.fileName = file.name
draft.contents = contents
setState(S.ready)
return contents
} catch (error) {
setState(S.failed)
throw error
}
},
},
})
const file = new File(["name,email\nAda,[email protected]"], "users.csv")
const request = fileImportMachine.event.importFile(file)
fileImportMachine.state === fileImportMachine.STATE.importing // true
const contents = await request
contents // CSV text returned by the event
fileImportMachine.data.fileName // "users.csv"
fileImportMachine.data.contents // latest snapshot
fileImportMachine.state === fileImportMachine.STATE.ready // trueIn one event handler, update the
draftonly before the firstawaitor after the lastawait. Do not update it between twoawaits.
Parallel async events share current work safely. When one finishes, it does not replace unrelated changes made while it was waiting.
States
Data can hold a status like isLoading too. States are declared and typed, and setState replaces the current one, so isLoading and isFailed can't both be true. The first declared state is the starting state.
const uploadMachine = createMachine({
states: ["idle", "uploading", "done", "failed"],
events: {
start: ({ setState, S }) => setState(S.uploading),
finish: ({ setState, S }) => setState(S.done),
},
})
uploadMachine.state === uploadMachine.STATE.idle // true
uploadMachine.event.start()
uploadMachine.state === uploadMachine.STATE.uploading // trueOutside an event, call machine.setState(machine.STATE.name).
Several active states
setState.enter and setState.exit add or remove one state and keep the others. setState replaces them all.
const playerMachine = createMachine({
states: ["idle", "playing", "buffering"],
events: {
play: ({ setState, S }) => setState(S.playing),
bufferStart: ({ setState, S }) => setState.enter(S.buffering),
bufferEnd: ({ setState, S }) => setState.exit(S.buffering),
stop: ({ setState, S }) => setState(S.idle),
},
})
playerMachine.event.play()
playerMachine.event.bufferStart() // playing and buffering
playerMachine.event.bufferEnd() // playingInside an event, check with hasState(S.buffering), which stays correct after an await. Outside, use (machine.state & machine.STATE.buffering) !== 0.
Subscribe to events
Use subscribeEvents to observe event names, arguments, and async results.
const stop = fileImportMachine.subscribeEvents(([name, args, success]) => {
console.log(name, args, success)
})
await fileImportMachine.event.importFile(file)
stop()A sync event sends one notification with success as undefined. An async event sends one when it starts, then another with true after it resolves or false after it rejects.
Initializer
Use data when a value is available during machine creation. An initializer is stored for an adapter to run later; createMachine does not run it.
const machine = createMachine({
initializer: () => ({ count: 0 }),
})@statekit/react runs the initializer the first time a component uses the machine in the browser.
StateKit packages
@statekit/coreruns the machine and owns its data, events, states, and selectors.@statekit/reactshares machines with React components and limits rerenders to the data each component reads.@statekit/connectorprovides the field tracking used by view adapters.@statekit/machine-messagebuilds a request cache on a StateKit machine.@statekit/eslint-pluginreports draft changes between awaits and destructuring that rerenders on every data change.
Do
- Change data through an event's
draft. - Use the event's
setStatewhen changing state inside an event. - Use
hasStateinstead of a destructuredstateafter anawait. - Await async events when you need their result.
- Read
machine.dataagain when you need the latest snapshot. - Call the function returned by
subscribeEventswhen you are done listening.
Don't
- Don't save a
draftand use it after the event ends. - Don't expect a saved snapshot to update by itself.
- Don't expect
createMachineto runinitializer.
Advanced
Transition rules
transitionRules limits where a state can move. For a named state, only lists the states it may move to, and deny lists the states it may not move to. A blocked transition throws.
const uploadMachine = createMachine({
states: ["idle", "uploading", "done", "failed"],
events: {
start: ({ setState, S }) => setState(S.uploading),
finish: ({ setState, S }) => setState(S.done),
},
transitionRules: {
deny: {
idle: ["done"],
},
only: {
uploading: ["done", "failed"],
},
},
})Don't place the same state in both only and deny.
Development
From the node directory:
pnpm --filter @statekit/core test -- --runInBand
pnpm --filter @statekit/core build