This guide helps developers familiar with XState transition to Statekit. Both libraries implement the statechart specification, but Statekit is designed specifically for Go backends.
| XState | Statekit |
|---|---|
| TypeScript/JavaScript | Go |
| Frontend/Node.js focused | Backend focused |
| Runtime-heavy | Compile-time validation |
| Actor model built-in | Explicit actor spawning |
| Dynamic interpretation | Static machine definition |
Statekit provides built-in visualization tools (statekit viz), removing the dependency on external services.
XState (TypeScript):
import { createMachine, interpret } from 'xstate';
const toggleMachine = createMachine({
id: 'toggle',
initial: 'inactive',
states: {
inactive: {
on: { TOGGLE: 'active' }
},
active: {
on: { TOGGLE: 'inactive' }
}
}
});
const service = interpret(toggleMachine).start();
service.send({ type: 'TOGGLE' });
console.log(service.getSnapshot().value); // 'active'Statekit (Go):
import "go.klarlabs.de/statekit"
type Context struct{}
machine, _ := statekit.NewMachine[Context]("toggle").
WithInitial("inactive").
State("inactive").
On("TOGGLE").Target("active").
Done().
State("active").
On("TOGGLE").Target("inactive").
Done().
Build()
interp := statekit.NewInterpreter(machine)
interp.Start()
interp.Send(statekit.Event{Type: "TOGGLE"})
fmt.Println(interp.State().Value) // "active"| XState Concept | Statekit Equivalent |
|---|---|
createMachine() |
statekit.NewMachine[C]() |
interpret() |
statekit.NewInterpreter() |
service.start() |
interp.Start() |
service.send() |
interp.Send() |
service.getSnapshot() |
interp.State() |
service.stop() |
interp.Stop() |
state.matches() |
interp.Matches() |
state.done |
interp.Done() |
const machine = createMachine({
id: 'counter',
initial: 'active',
context: {
count: 0,
user: null
},
states: {
active: {
on: {
INCREMENT: {
actions: assign({ count: (ctx) => ctx.count + 1 })
}
}
}
}
});Context is a type parameter, providing compile-time type safety:
type CounterContext struct {
Count int
User *User
}
machine, _ := statekit.NewMachine[CounterContext]("counter").
WithInitial("active").
WithContext(CounterContext{Count: 0}). // Optional initial context
WithAction("increment", func(ctx *CounterContext, e statekit.Event) {
ctx.Count++
}).
State("active").
On("INCREMENT").Target("active").Do("increment").
Done().
Build()
// Access context
state := interp.State()
fmt.Println(state.Context.Count)
// Update context directly
interp.UpdateContext(func(ctx *CounterContext) {
ctx.User = &User{Name: "Alice"}
})Key difference: Statekit passes *Context to actions (mutable pointer), not a copy.
const machine = createMachine({
id: 'order',
initial: 'pending',
context: { items: [] },
states: {
pending: {
entry: 'logEntry',
exit: 'logExit',
on: {
SUBMIT: {
target: 'processing',
actions: ['validateOrder', 'notifyUser']
}
}
},
processing: { /* ... */ }
}
}, {
actions: {
logEntry: (ctx, event) => console.log('Entered pending'),
logExit: (ctx, event) => console.log('Exited pending'),
validateOrder: (ctx, event) => { /* ... */ },
notifyUser: (ctx, event) => { /* ... */ }
}
});machine, _ := statekit.NewMachine[OrderContext]("order").
WithInitial("pending").
// Register actions on the machine builder
WithAction("logEntry", func(ctx *OrderContext, e statekit.Event) {
fmt.Println("Entered pending")
}).
WithAction("logExit", func(ctx *OrderContext, e statekit.Event) {
fmt.Println("Exited pending")
}).
WithAction("validateOrder", func(ctx *OrderContext, e statekit.Event) {
// Validation logic
}).
WithAction("notifyUser", func(ctx *OrderContext, e statekit.Event) {
// Notification logic
}).
State("pending").
OnEntry("logEntry").
OnExit("logExit").
On("SUBMIT").Target("processing").Do("validateOrder").Do("notifyUser").
Done().
State("processing").
// ...
Done().
Build()Key differences:
- Actions are registered on the machine builder with
WithAction() - Action names are strings, implementations are Go functions
- Chain multiple actions:
.Do("action1").Do("action2")
const machine = createMachine({
id: 'payment',
initial: 'idle',
context: { balance: 100, amount: 0 },
states: {
idle: {
on: {
PAY: [
{ target: 'success', guard: 'hasEnoughBalance' },
{ target: 'failed' }
]
}
},
success: { type: 'final' },
failed: { type: 'final' }
}
}, {
guards: {
hasEnoughBalance: (ctx) => ctx.balance >= ctx.amount
}
});machine, _ := statekit.NewMachine[PaymentContext]("payment").
WithInitial("idle").
WithGuard("hasEnoughBalance", func(ctx PaymentContext, e statekit.Event) bool {
return ctx.Balance >= ctx.Amount
}).
State("idle").
On("PAY").Target("success").Guard("hasEnoughBalance").
On("PAY").Target("failed"). // Fallback (no guard = always true)
Done().
State("success").Final().Done().
State("failed").Final().Done().
Build()Key differences:
- Guards receive
Contextby value (not pointer) - they're read-only - Guard ordering matters - first matching transition wins
- Fallback transitions don't need explicit guard
const machine = createMachine({
id: 'app',
initial: 'authenticated',
states: {
authenticated: {
initial: 'dashboard',
on: {
LOGOUT: 'unauthenticated' // Applies to all child states
},
states: {
dashboard: {
on: { VIEW_SETTINGS: 'settings' }
},
settings: {
on: { BACK: 'dashboard' }
}
}
},
unauthenticated: {
on: { LOGIN: 'authenticated' }
}
}
});machine, _ := statekit.NewMachine[Context]("app").
WithInitial("authenticated").
State("authenticated").
WithInitial("dashboard").
On("LOGOUT").Target("unauthenticated").End(). // Parent-level transition
State("dashboard").
On("VIEW_SETTINGS").Target("settings").
End().End(). // End transition, End child state
State("settings").
On("BACK").Target("dashboard").
End().End().
Done(). // Complete parent compound state
State("unauthenticated").
On("LOGIN").Target("authenticated").
Done().
Build()Builder pattern:
.End()returns to the parent builder (StateBuilder or TransitionBuilder).Done()completes a top-level state and returns to MachineBuilder- For child states:
State("child").On("X").Target("y").End().End()- first End() ends transition, second ends child state
const machine = createMachine({
id: 'editor',
type: 'parallel',
states: {
bold: {
initial: 'off',
states: {
off: { on: { TOGGLE_BOLD: 'on' } },
on: { on: { TOGGLE_BOLD: 'off' } }
}
},
italic: {
initial: 'off',
states: {
off: { on: { TOGGLE_ITALIC: 'on' } },
on: { on: { TOGGLE_ITALIC: 'off' } }
}
}
}
});machine, _ := statekit.NewMachine[Context]("editor").
WithInitial("active").
State("active").Parallel().
Region("bold").WithInitial("off").
State("off").On("TOGGLE_BOLD").Target("on").EndState().
State("on").On("TOGGLE_BOLD").Target("off").EndState().
EndRegion().
Region("italic").WithInitial("off").
State("off").On("TOGGLE_ITALIC").Target("on").EndState().
State("on").On("TOGGLE_ITALIC").Target("off").EndState().
EndRegion().
Done().
Build()
// Check region states
state := interp.State()
fmt.Println(state.ActiveInParallel["bold"]) // "on" or "off"
fmt.Println(state.ActiveInParallel["italic"]) // "on" or "off"Key differences:
- Regions are explicit with
.Region("name") - Use
.EndState()for states inside regions - Use
.EndRegion()to close a region - Access region states via
ActiveInParallelmap
const machine = createMachine({
id: 'wizard',
initial: 'form',
states: {
form: {
initial: 'step1',
states: {
step1: { on: { NEXT: 'step2' } },
step2: { on: { NEXT: 'step3', PREV: 'step1' } },
step3: { on: { PREV: 'step2' } },
hist: { type: 'history', history: 'shallow' }
},
on: {
HELP: 'help'
}
},
help: {
on: {
BACK: 'form.hist' // Return to last form step
}
}
}
});machine, _ := statekit.NewMachine[Context]("wizard").
WithInitial("form").
State("form").
WithInitial("step1").
On("HELP").Target("help").End().
History("hist").Shallow().Default("step1").End().
State("step1").On("NEXT").Target("step2").End().End().
State("step2").
On("NEXT").Target("step3").
On("PREV").Target("step1").
End().End().
State("step3").On("PREV").Target("step2").End().End().
Done().
State("help").
On("BACK").Target("hist"). // Target the history pseudo-state
Done().
Build()Key differences:
- Define history with
.History("name").Shallow().Default("fallback").End() - Or use
.Deep()for deep history - Target history state by its ID:
.Target("hist")
const machine = createMachine({
id: 'session',
initial: 'active',
states: {
active: {
after: {
5000: 'warning'
},
on: {
ACTIVITY: 'active'
}
},
warning: {
after: {
5000: 'expired'
},
on: {
ACTIVITY: 'active'
}
},
expired: { type: 'final' }
}
});import "time"
machine, _ := statekit.NewMachine[Context]("session").
WithInitial("active").
State("active").
After(5 * time.Second).Target("warning").
On("ACTIVITY").Target("active").
Done().
State("warning").
After(5 * time.Second).Target("expired").
On("ACTIVITY").Target("active").
Done().
State("expired").Final().Done().
Build()Key differences:
- Use Go's
time.Durationinstead of milliseconds .After(duration).Target("state")- Timers auto-cancel on state exit
- Guards work:
.After(5*time.Second).Target("x").Guard("condition")
const machine = createMachine({
id: 'payment',
initial: 'processing',
states: {
processing: {
on: {
SUCCESS: 'completed',
FAILURE: 'failed'
}
},
completed: { type: 'final' },
failed: { type: 'final' }
}
});machine, _ := statekit.NewMachine[Context]("payment").
WithInitial("processing").
State("processing").
On("SUCCESS").Target("completed").
On("FAILURE").Target("failed").
Done().
State("completed").Final().Done().
State("failed").Final().Done().
Build()
interp := statekit.NewInterpreter(machine)
interp.Start()
interp.Send(statekit.Event{Type: "SUCCESS"})
if interp.Done() {
fmt.Println("Machine completed")
}service.send({ type: 'UPDATE', data: { name: 'Alice', age: 30 } });
// Access in action
const machine = createMachine({
// ...
}, {
actions: {
updateUser: (ctx, event) => {
ctx.user = event.data;
}
}
});// Event with payload (use Payload field, not Data)
interp.Send(statekit.Event{
Type: "UPDATE",
Payload: map[string]any{
"name": "Alice",
"age": 30,
},
})
// Access in action with type assertion
WithAction("updateUser", func(ctx *Context, e statekit.Event) {
if data, ok := e.Payload.(map[string]any); ok {
ctx.User.Name = data["name"].(string)
ctx.User.Age = data["age"].(int)
}
})Key difference: Use Event.Payload (type any) - requires type assertions in Go.
const machine = createMachine({
id: 'fetch',
initial: 'loading',
states: {
loading: {
invoke: {
id: 'fetchData',
src: async () => {
const response = await fetch('/api/data');
return response.json();
},
onDone: { target: 'success' },
onError: { target: 'failure' }
}
},
success: { type: 'final' },
failure: { type: 'final' }
}
});import (
"context"
"net/http"
)
machine, _ := statekit.NewMachine[FetchContext]("fetch").
WithInitial("loading").
// Register service with a name
WithService("fetchData", func(svc statekit.ServiceContext[FetchContext]) error {
ctx := svc.Context.(context.Context) // For cancellation
req, _ := http.NewRequestWithContext(ctx, "GET", "/api/data", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err // Triggers OnError
}
defer resp.Body.Close()
// Send result back as event
svc.Send(statekit.Event{
Type: "DATA_LOADED",
Payload: resp,
})
return nil // Success triggers OnDone
}).
State("loading").
Invoke("fetchData").
OnDone("success").
OnError("failure").
End().
Done().
State("success").Final().Done().
State("failure").Final().Done().
Build()Key differences:
- Register services with
WithService("name", func) - Service receives
ServiceContextwith context for cancellation, machine context (read-only), andSendfunction - Return
errorfor failure,nilfor success - Use
.Invoke("serviceName")on state
const persistedState = JSON.stringify(service.getSnapshot());
localStorage.setItem('machine-state', persistedState);
const restoredState = JSON.parse(localStorage.getItem('machine-state'));
const service = interpret(machine).start(restoredState);// Save state
snapshot := interp.Snapshot()
data, _ := json.Marshal(snapshot)
// Save to file/database...
// Restore state
var snapshot statekit.Snapshot[MyContext]
json.Unmarshal(data, &snapshot)
newInterp := statekit.NewInterpreter(machine)
newInterp.Restore(snapshot)
// Continue from restored stateKey difference: Snapshot is a typed struct that includes context, history, parallel state, and pending timers.
Statekit provides a plugin system for extending interpreter behavior:
import "go.klarlabs.de/statekit/plugin"
// Implement plugin interfaces
type LoggingPlugin[C any] struct{}
func (p *LoggingPlugin[C]) Name() string { return "logging" }
func (p *LoggingPlugin[C]) OnEnter(ctx plugin.Context[C], state plugin.StateID) {
log.Printf("Entered: %s", state)
}
func (p *LoggingPlugin[C]) OnExit(ctx plugin.Context[C], state plugin.StateID) {
log.Printf("Exited: %s", state)
}
// Register plugin
interp.Use(&LoggingPlugin[MyContext]{})XState has similar extensibility through the inspect API.
For a more XState-like declarative syntax:
type OrderMachine struct {
statekit.MachineDef `id:"order" initial:"pending"`
Pending statekit.StateNode `on:"SUBMIT->processing"`
Processing statekit.StateNode `on:"APPROVE->approved,REJECT->rejected"`
Approved statekit.FinalNode
Rejected statekit.FinalNode
}
registry := statekit.NewActionRegistry[OrderContext]()
machine, _ := statekit.FromStruct[OrderMachine, OrderContext](registry)| Aspect | XState | Statekit |
|---|---|---|
| Language | TypeScript/JavaScript | Go |
| Type safety | Runtime (with TS) | Compile-time (generics) |
| Machine creation | Object literal | Fluent builder |
| Context mutation | assign() action |
Pointer in action |
| Guards | guard: fn |
.Guard("name") |
| Async | Promises/async-await | Goroutines |
| Event payload | event.data |
event.Payload |
| Plugins | inspect, middleware |
Plugin interfaces |
| Visualization | Native | Native (HTML, Mermaid, TUI) |
- Convert machine definition to fluent builder or reflection DSL
- Define context as a Go struct with proper types
- Convert actions to
func(*Context, Event)signature - Convert guards to
func(Context, Event) boolsignature - Replace
assign()with direct context pointer mutation - Replace promises with goroutines for async (via services)
- Update event payload:
event.data→event.Payload - Use
statekit vizfor visualization - Add plugins for logging/metrics if needed