effect-state-machine
How-to guides

Persist machines in browser localStorage

Configure the browser-local MachineStore with Web Locks or an explicit single-context mode.

Use LocalStorageMachineStore for small same-origin machines that must survive a page reload. The adapter stores one canonical JSON aggregate per machine instance. Because localStorage offers no compare-and-set operation, safe cross-tab use also requires the Web Locks API.

Construct browser capabilities at the client boundary

Core modules never read window, localStorage, or navigator. Pass those capabilities when the application constructs its Layer:

import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as LocalStorageMachineStore from "effect-state-machine/LocalStorageMachineStore"
import * as MachineEngine from "effect-state-machine/MachineEngine"

const MachineLive = MachineEngine.layer().pipe(
  Layer.provide(
    LocalStorageMachineStore.layer({
      namespace: "my-app:machines",
      storage: window.localStorage,
      locks: LocalStorageMachineStore.webLocks(navigator.locks),
    }),
  ),
)

const program = Effect.scoped(definition.run(input)).pipe(
  Effect.provide(MachineLive),
)

Build this Layer only in browser code. Importing the adapter on a server is safe because the module itself has no browser-global access; evaluating window.localStorage or navigator.locks is the application's platform boundary.

Treat missing Web Locks as unsupported

The coordinated layer fails with UnsupportedPlatform when no lock capability is supplied. Do not silently fall back to unlocked writes: two tabs could load the same revision and both believe their replacement committed.

When the application can prove that exactly one JavaScript context accesses the namespace, opt in with the deliberately explicit API:

const SamePageOnly = LocalStorageMachineStore.layerSingleContext({
  namespace: "my-embedded-widget",
  storage: window.localStorage,
})

This mode is not safe for multiple tabs, workers, frames, or independently mounted copies that can write the same instance.

Reopen known instances after reload

The minimal store loads by derived instance ID; it does not globally discover dormant machines. Reopen a known logical instance with the same definition and input identity:

const handle = yield* Order.open({ orderId: "42" })

The initializer is not called again. Stored timer deadlines remain absolute: a timer resumes with its remaining duration, or becomes eligible immediately when its deadline passed while the page was closed.

Know when to choose another adapter

localStorage is synchronous and quota-limited. Whole-document replacement amplifies writes as a machine grows. Prefer a future IndexedDB adapter or a server-backed SQL/Redis adapter for large aggregates, high event rates, many activity claims, or execution shared across devices.

On this page