Your first actor
A counter that survives restarts, never loses an increment, and pushes changes to every open tab. Four files, no database.
This is examples/counter from the actors repo, which runs this exact shape in dev and in
production.
1. The app module
One source of truth for storage, defaults and plugins — loaded by the dev server, the production entry and your actor modules alike.
// src/actors.app.ts
import { defineActorApp } from '@sigx/actors/host';
import { fileStorage } from '@sigx/actors/node';
export const app = defineActorApp({
// Keeps dev state across actor-file edits and restarts.
storage: fileStorage({ dir: '.actors' }),
});
/** Bound to this app's plugin set — actor modules import this. */
export const { defineActor } = app;
2. The actor
// src/counter.actor.ts
import { defineActor } from './actors.app.ts';
export const Counter = defineActor({
type: 'Counter',
// This demo counter is deliberately public. Real actors declare a
// `use: [...]` chain — the build gate insists you pick one.
unguarded: true,
state: () => ({ count: 0, lastVisit: null as Date | null }),
methods: (ctx) => ({
async increment(by: number) {
ctx.state.count += by;
ctx.state.lastVisit = new Date();
await ctx.save(); // persistence is explicit — state saves only when asked
return ctx.state.count;
},
async current() {
return { count: ctx.state.count, lastVisit: ctx.state.lastVisit };
},
}),
});
Four things are already true of this object, and none of them are things you wrote:
- Two browsers clicking at once cannot lose an increment —
incrementruns one turn at a time, soctx.state.count += byneeds no lock. - Two different
nameprops are two different actors with separate state, created on demand. lastVisitis a realDatein the browser, not a string — the serialize codec carries it across.- The actor deactivates when idle and reloads its state on the next call.
3. Read and write it from a component
Register the plugin once, so components can reach the actor:
// src/main.tsx
import { actorsPlugin } from '@sigx/actors/app';
app.use(actorsPlugin());
Then the actor is just data and an action:
// src/Counter.tsx
import { component } from 'sigx';
import { useActorAction, useActorState } from '@sigx/actors/app';
import { Counter } from './counter.actor';
export const CounterView = component<{ name: string }>(({ props }) => {
// Re-runs after every turn that changes this actor's state — in any tab.
const state = useActorState(Counter, props.name, 'current', { live: true });
const increment = useActorAction(Counter, props.name, 'increment');
return () => (
<>
{state.match({
pending: () => <p>…</p>,
ready: (s) => (
<p>
{s.count} — last visit {s.lastVisit?.toLocaleTimeString() ?? '–'}
</p>
),
})}
<button disabled={increment.loading} onClick={() => increment.run([1])}>
+1
</button>
</>
);
});
Four things happened there without you asking:
counter.actor.tswas build-swapped, soCounteris a typed client ref andincrement.run([1])is aPOSTto/_sigx/actor. Rename the method and this file fails to compile — there is no second interface to keep in sync.currentwas read on the server during SSR and serialized into the page, so first paint has the count already and hydration costs no request.{ live: true }means the actor pushes a new result after every turn that changed its state, from any tab — see Live reads.- The write refreshed the read.
increment.run()invalidates this counter's reads by default, so nothing callsrefresh()by hand.
4. Run it
// vite.config.ts
import { sigxActors } from '@sigx/actors/vite';
export default {
plugins: [sigxActors({ app: '/src/actors.app.ts' })],
server: { watch: { ignored: ['**/.actors/**'] } },
};
pnpm dev
Open the page twice. Click in one tab and both update — the actor re-ran current after
the turn that changed its state and pushed the result to every live reader.
Stop the dev server and start it again — the count is still there, in .actors/.
What to try next
- Remove
await ctx.save()and restart. The count resets: persistence is explicit, and that is deliberate. - Add
await new Promise(r => setTimeout(r, 3000))insideincrement, then click twice quickly. The second click waits — that is guarantee 3,awaitkeeping the turn open. - Replace
unguarded: truewith a real guard chain.
Next steps
- The app — plugins, context extensions and lifecycle.
- The actor model — activation, turns and identity in depth.
- State & persistence —
ctx.save(), write-behind and migrations. - Running a host on Node — the production entry and graceful shutdown.
