How-to guide

Drive a machine instance from React

createMachineServer(endpoint, { machine, documents, socket }), from @traxsharp/state-machine/client, binds one exposed machine's typed operations and its push stream into the MachineServer that useServerMachine (from @traxsharp/state-machine/react) and its framework-free ServerMachineController drive an instance through. The server applies every transition, stores it and answers what it stored; the twin only predicts.

Bind the server and use the hook

import { createClient } from 'graphql-ws';
import { createMachineServer, persistedMessageReplacer } from '@traxsharp/state-machine/client';
import { useServerMachine } from '@traxsharp/state-machine/react';
import documents from './topic-map.documents.json';
 
const server = createMachineServer('/trax/graphql', {
  headers: () => ({ 'X-Api-Key': key }),
  machine: topicMap,
  documents,
  socket: createClient({
    url: 'wss://host/trax/graphql',
    connectionParams: { apiKey: key },
    lazy: true,
    retryAttempts: Infinity,
    jsonMessageReplacer: persistedMessageReplacer,
  }),
});
 
function Wizard({ id }: { id?: string }) {
  const map = useServerMachine(topicMap, { server, id });   // without id, it starts an instance
  if (map.error) return <p>{map.error.code}</p>;           // gone, client-outdated (reload), or CONNECTION_CHANGED
  return <button disabled={!map.can('Back')} onClick={() => map.send('Back')}>Back</button>;
}

What createMachineServer does

  • Sends every operation by its persisted id, through the same executor as createMachineExecutor, so the same document checks apply. documents must hold the push document (changed), or it throws when it is built.
  • Subscribes by id over the socket you pass, with an empty document: graphql-ws requires one, and a host that runs persisted documents refuses one beside an id, so pass persistedMessageReplacer as the client's jsonMessageReplacer and it leaves the empty query out. The package does not depend on graphql-ws; any socket with its subscribe(payload, sink) shape (PersistedSubscriptionSocket) does.
  • Maps the stream: a snapshot with its rowId and revision; gone: true as gone, and the NOT_FOUND a missing id is answered with as gone with notFound; CLIENT_OUTDATED as client-outdated; reload: true as a read. A socket error whose code is in terminalStreamErrors ends the stream for good: it reaches the controller as ended, and is not subscribed again. A request rejected with one of those codes is not a transport failure either: the server's endedBy names it, and the controller stops on it the same way. The generated client lists CONNECTION_CHANGED there, which a server made before the last configureTrax refuses every request and stream with. A stream that ends any other way (its host stopped, the socket gave up, TOO_MANY_SUBSCRIPTIONS) is subscribed again after resubscribeDelays (1, 2, then every 5 seconds); the new stream's first event is the instance as it is then, so nothing is read again.
  • Adds discard(id, requestId), for a page that throws an instance away.

What useServerMachine does

useServerMachine(machine, { server, id?, startInput?, startRequestId?, startStorageKey?, newRequestId?, retryDelays? }):

  • Opens or starts. With id it subscribes, then reads the instance. Without one it calls start once, under startRequestId when you keep one (across a reload, so a start that was in flight answers with the instance it created) and a minted one otherwise; StrictMode's second mount reuses it. A new startRequestId starts a new instance. A start request id the hook minted is kept in sessionStorage under startStorageKey (by default trax-start:<machine id>; null keeps nothing) until the start answers, so a reload while it is unanswered, or after it failed, sends the same id and gets the instance the first request created. Give each hook that starts the same machine on one page its own key. Without sessionStorage nothing is kept.
  • Predicts, then confirms. send(trigger, input) queues the event behind any earlier one of the instance, sends it under its own requestId with the twin's prediction as clientResult, and shows the prediction until the answer or a push replaces it. A refusal or a transport failure rolls it back and sets problem; CLIENT_DIVERGENCE also reads the instance again. SERVER_OUTDATED is retried under the same requestId after each of retryDelays. The twin holds no handler for a custom rule, so it predicts nothing that needs one: such an event is sent without a prediction, and the answer is what the hook shows. An effect-bound event (its edge runs an effect, such as a charge) is sent with no input and no prediction, since the effect produces what its edge records, and nothing queued after it is predicted until it answers; can reads its guard with every rule on the input, and every custom rule, counted as passing, so a rule on the context still decides.
  • Trusts the server on custom rules. A snapshot the server sends is checked for its shape and every built-in rule; a custom rule the twin has no handler for counts as passed, because the server evaluated it. A snapshot that fails a check it can make stops the hook with CLIENT_OUTDATED.
  • Orders by revision. Anything at or below the revision held is dropped, the hook's own answers included; it reads the instance again only after reload, an incarnation change or a CLIENT_DIVERGENCE.
  • Stops for good on gone, CLIENT_OUTDATED, ended, or a request the server's endedBy names: status is stopped, error.code says which (with ended or endedBy, the code given, such as CONNECTION_CHANGED), nothing more is sent, retry() answers stopped, and a start request id the hook kept is forgotten. With GONE, error.deleted is true when the stream said the instance was deleted or expired, and false when the server answered NOT_FOUND, which it answers alike for a missing instance and another caller's. dueAt is shown, never fired: the server runs a state's timer and pushes the move. It is taken from every answer and push the hook accepts, and null always clears it: the state has no timer, or the server gave up on it and cleared it. An answer that does not carry the field, or cannot know it (dueAtKnown: false, a replay whose recorded revision the row has moved past, which createMachineServer delivers with no dueAt), keeps the due time held while the state stays the same; in a timed state it otherwise reads the instance once.
  • Retries on request. A transport failure is not retried by itself: retry() resends the failed start, or the last event that failed (in transport, or SERVER_OUTDATED past every retry), under its first request id, so the server replays its result rather than applying it twice. A later event the server confirms supersedes the failed one: resending it then would apply it after the later one, so retry() answers NOTHING_TO_RETRY. A refused start is not resent. send again would be a new request.
  • Moves with its options. A new server, machine, id or startRequestId closes the old instance's stream and opens the new one. From that render on the hook is loading and sends nothing, so a page whose server changed (another user signed in) never shows the instance before it under the new caller.
  • Mints request ids with newRequestId, crypto.randomUUID by default, which never makes one the server refuses. A custom one must keep to the server's rule: 1 to 200 characters, not blank, with no control character and no unpaired surrogate; anything else is answered INVALID_REQUEST_ID.

It returns status, id, snapshot, state, context, revision, dueAt, pending, problem, error, send, retry, can and available. The Recovery sample's topic map is built on it (Recovery).

SDK Reference

Machines root | @traxsharp/state-machine