@statekit/react
v0.13.3-dev
Published
Readme
@statekit/react
Use typed StateKit machines in React.
Work in progress.
For more machine features, see @statekit/core
Install
npm i @statekit/react react@statekit/react supports React 18 and later.
Add StatekitProvider near the root of every React tree that uses StateKit:
import { createRoot } from "react-dom/client"
import { StatekitProvider } from "@statekit/react"
import { App } from "./App"
createRoot(document.getElementById("root")!).render(
<StatekitProvider>
<App />
</StatekitProvider>
)Quick start
import { createMachine, createMachineContext } from "@statekit/react"
const inviteMachine = createMachine({
data: {
inviteUrl: "https://example.com/invite/team-42",
copies: 0,
message: "Ready to share",
},
events: {
copyLink: async ({ draft }) => {
draft.message = "Copying"
await navigator.clipboard.writeText(draft.inviteUrl)
draft.copies++
draft.message = "Copied"
},
},
selectors: {
hasBeenCopied: ({ data }) => data.copies > 0,
},
})
const [useInviteMachine] = createMachineContext(inviteMachine)
export const InviteLink = () => {
const m = useInviteMachine()
return (
<section>
<p>Invite link: {m.data.inviteUrl}</p>
<button onClick={m.event.copyLink}>Copy invite link</button>
<p>{m.data.message}</p>
<p>
{m.select.hasBeenCopied()
? `Copied ${m.data.copies} times`
: "Not copied yet"}
</p>
</section>
)
}createMachineContext returns [useMachine, Provider]. useInviteMachine returns the machine API as m and tracks the data, state, and selectors the component reads. Every component that calls it shares the same inviteMachine.
m.event.copyLink is the machine event itself. It shows Copying immediately, waits for the clipboard, then records the copy and shows Copied. React receives both updates from one async event.
What the hook returns
The hook returns the machine itself, with reads tracked for this component:
m.data- current data;m.event- functions that change data or state;m.select- named reads of data;m.stateandm.STATE- optional workflow states.
m.setData, m.setState, and m.subscribeEvents are also available when you need lower-level control.
Name the hook and the value after the machine. When a component is built around one machine, m is enough:
const m = useInviteMachine()When it uses several, give each value a specific name:
const cartMachine = useCartMachine()Public and private machines
Machines are public by default. Their hook works in any component, and no machine provider is needed.
Make a machine private when its hook should only work below its provider:
const checkoutMachine = createMachine(
{
data: { step: 1 },
},
{ private: true }
)
const [useCheckoutMachine, CheckoutProvider] = createMachineContext(checkoutMachine)Calling useCheckoutMachine above CheckoutProvider throws. The provider controls where the machine can be used; it does not create a new copy. Every provider and hook from one createMachineContext share the same machine.
To make every machine private unless it says otherwise, call setDefaultMachineOptions once, before importing any module that creates a machine:
import { setDefaultMachineOptions } from "@statekit/react"
setDefaultMachineOptions({ private: true })A machine created with { private: false } stays public.
Use a hook without a separate provider component
wrap mounts a machine provider around the component that calls its hook. Component props still pass through.
import { wrap } from "@statekit/react"
export const Checkout = wrap(CheckoutProvider, () => {
const m = useCheckoutMachine()
return <p>Step {m.data.step}</p>
})Pass an array to mount several machine providers. The last provider becomes the outermost one:
export const Checkout = wrap(
[CheckoutProvider, CartProvider, SessionProvider],
() => <CheckoutView />
)Here, SessionProvider is outermost and CheckoutProvider is innermost.
You can also place a provider directly in JSX when a machine belongs to a whole route or layout.
Reads control rerenders
Each call to useInviteMachine tracks the fields that component reads.
- A component that reads
m.data.copiesrerenders whencopieschanges. - A component that reads only
copiesdoes not rerender whenmessagechanges. - Reading
m.statewatches machine state. - A selector watches the fields it reads, so
m.select.hasBeenCopied()watchescopies.
Nested objects, arrays, object keys, and array methods are tracked. Read only the data the component needs.
Date, Map, Set, RegExp, and Promise values are returned as they are. A component that reads one rerenders when it is replaced by a new instance, not when something inside it changes. Prefer plain objects, arrays, strings, numbers, and booleans in data that components read.
States
A component that reads m.state rerenders when the state changes:
const uploadMachine = createMachine({
states: ["idle", "uploading", "done", "failed"],
events: {
start: ({ setState, S }) => setState(S.uploading),
finish: ({ setState, S }) => setState(S.done),
},
})
const [useUploadMachine] = createMachineContext(uploadMachine)
const UploadStatus = () => {
const m = useUploadMachine()
return m.state === m.STATE.uploading ? <p>Uploading…</p> : <p>Waiting.</p>
}Comparing with === works while one state is active. When several states can be active at once, check with (m.state & m.STATE.uploading) !== 0. Several active states, and advanced options like transition rules, are covered in @statekit/core.
Call events outside React
Events can be called from normal functions or other modules. Mounted components still update.
await inviteMachine.event.copyLink()
inviteMachine.data.copies // latest snapshotYou can also observe every event:
const stop = inviteMachine.subscribeEvents(([name, args, success]) => {
console.log(name, args, success)
})
await inviteMachine.event.copyLink()
stop()Initializer and server rendering
An initializer runs once, in the browser, the first time a component uses the machine. It does not run during server rendering.
const preferencesMachine = createMachine({
data: { theme: "system" },
initializer: (data) => ({
...data,
theme: localStorage.getItem("theme") ?? data.theme,
}),
})Use an initializer for data that must be created in the browser, such as localStorage values.
For large apps with many machines,
@shortkit/make-lazycan delay machine creation until first use.
StateKit packages
@statekit/coreruns the machine outside any view library.@statekit/connectortracks field reads for React and other adapters.@statekit/machine-messageadds a small request cache built on a StateKit machine.@statekit/decorationscreates config wrappers that add reusable behavior to machines.@statekit/decoration-tanstack-querywrites a TanStack Query's data, loading state, and error into a machine.@statekit/eslint-pluginreports draft changes between awaits and destructuring that rerenders on every data change.
Direct comparison
The difference is easiest to see in everyday code.
| Task | StateKit | Redux Toolkit | TanStack Query | Zustand |
| ------------------ | ------------------------------------------------ | ------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| Best fit | Data, events, and workflows that belong together | General app state with Redux tooling | Fetching and caching server data | Small, direct client stores |
| Read in React | m.data.copies | useSelector((s) => s.copies) | query.data | useStore((s) => s.copies) |
| Use as a handler | m.event.copyLink | () => dispatch(copyLink()) | () => mutation.mutate() | copyLink |
| Read outside React | inviteMachine.data.copies | store.getState().copies | queryClient.getQueryData(...) | useStore.getState().copies |
| Call outside React | inviteMachine.event.copyLink() | store.dispatch(copyLink()) | queryClient.fetchQuery(...) | useStore.getState().copyLink() |
| Limit rerenders | Automatic from reads | Write a selector | Query subscription | Write a selector |
| Async work | One async function | Thunk or RTK Query | Built in for requests | Async action |
| Share as a library | Export one machine | Export actions and a reducer; the app adds it to its store | Export query helpers; the app supplies a QueryClient | Export a store or hook |
StateKit keeps client code direct: read data, call events, and use the same machine anywhere. @statekit/machine-message adds a small request cache; TanStack Query remains stronger when server caching is the main problem, and the two can be used together.
Do
- Create a shared machine and its context outside components.
- Add
StatekitProvidernear the root of the app. - Use
const m = useInviteMachine()inside the component. - Read only the data the component displays.
- Make a machine private when it must stay below a provider.
- Call machine events from other modules when needed.
Don't
- Don't mutate
m.datadirectly; change data through events. - Don't call a private machine's hook above its provider.
- Don't expect a provider to create a separate copy of the machine.
- Don't call
setDefaultMachineOptionsafter machines have been created. - Don't update the draft in a single event between two awaits, only at the start or at the end.
@statekit/eslint-plugin reports draft updates between awaits, and destructuring that re-renders a component on every data change.
Development
From the node directory:
pnpm --filter @statekit/react test -- --runInBand
pnpm --filter @statekit/react build