A type-safe Statechart library for Go, inspired by XState.
gstate allows you to model complex application logic using finite state machines and statecharts. Unlike traditional logic scattered across if/else blocks and boolean flags, statecharts provide a formal, visual, and structured way to define how your system behaves.
A Statechart is an extension of a Finite State Machine (FSM). While a basic FSM has a set of states and transitions, a Statechart adds:
- Hierarchy: States can contain other states (Nested States).
- Orthogonality: Multiple states can be active at once (Parallel States).
- Broadcast: Events can trigger transitions in multiple regions.
- History: The ability to "remember" where you were before leaving a state.
go get github.com/floodfx/gstateEvery statechart starts with three core concepts:
- State: A specific condition or "mode" of your system (e.g.,
Idle,Loading,Success). - Event: Something that happens (e.g.,
START,MOUSE_CLICK,TIMEOUT). - Transition: A rule that says: "When in state A, if event E happens, move to state B."
type MyState string
type MyEvent string
const (
StateOff MyState = "off"
StateOn MyState = "on"
)
const (
EventToggle MyEvent = "TOGGLE"
)
machine := gstate.New[MyState, MyEvent, any]("toggle").
Initial(StateOff).
State(StateOff, func(s *gstate.StateBuilder[MyState, MyEvent, any]) {
s.On(EventToggle).GoTo(StateOn)
}).
State(StateOn, func(s *gstate.StateBuilder[MyState, MyEvent, any]) {
s.On(EventToggle).GoTo(StateOff)
}).
Build()One of the core strengths of gstate is its use of Go 1.18+ generics to provide strict type safety.
The library uses three generic parameters: [S ~string, E ~string, C any].
S(State ID): By using a custom string type (e.g.,type MyState string), you ensure thatInitial(),State(), andGoTo()only accept valid state identifiers.E(Event ID): Similarly,On(event)only accepts events of your specific type.C(Context): The data your machine holds is strictly typed. Actions and guards receive this exact type, eliminating the need forinterface{}casting.
Benefits:
- No Typos: Compilers will catch
actor.Send("TYPO")if your event type is strictly defined. - IDE Support: Autocomplete works for states, events, and context fields.
- Safety: Guards and Actions are verified at compile time to work with your specific data structure.
Statecharts aren't just about labels; they often need to hold data. In gstate, this is called Context.
Transitions can perform Actions to update this data. In Go, these are pure functions: func(C) C.
type CounterCtx struct {
Count int
}
s.On("INCREMENT").
Assign(func(c CounterCtx) CounterCtx {
c.Count++
return c
})If your Context contains reference types (pointers, slices, maps), Snapshot() and Context() might suffer from race conditions or shared state. You can implement the Cloner interface to provide a deep copy:
type MyCtx struct {
Data []int
}
func (c MyCtx) Clone() MyCtx {
newData := make([]int, len(c.Data))
copy(newData, c.Data)
return MyCtx{Data: newData}
}States can define actions that run whenever they are entered or exited. This is useful for setup/teardown, logging, or any side effect tied to a state's lifecycle.
s.State(StateActive, func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
s.Entry(func(c MyContext) MyContext {
fmt.Println("[active] Entering state...")
return c
})
s.Exit(func(c MyContext) MyContext {
fmt.Println("[active] Leaving state...")
return c
})
s.On(EventStop).GoTo(StateIdle)
})Entryruns when the state is entered, before any child states are resolved.Exitruns when the state is left, as part of the transition.
In a complex system, some states are "sub-modes" of others. For example, a User state might have Guest and LoggedIn sub-states.
Why use this?
- Bubbling: If a child state doesn't handle an event, it "bubbles up" to the parent.
- Organization: Group related logic together.
- Common Actions: Define an
Entryaction on a parent that runs regardless of which child is entered.
s.State("parent", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
s.Initial("childA")
// If ANY child receives "RESET", we go to "parent.childA"
s.On("RESET").GoTo("childA")
s.State("childA", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) { ... })
s.State("childB", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) { ... })
})History allows a compound state to remember which of its children was active before it was exited. When you re-enter the state, it resumes where it left off instead of going to the Initial child.
Two history types are available:
gstate.Shallow: Remembers the direct child that was active.gstate.Deep: Remembers all active descendants in the hierarchy.
machine := gstate.New[MyState, MyEvent, MyContext]("history_demo").
Initial("app").
State("app", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
s.History(gstate.Shallow)
s.Initial("screen1")
s.State("screen1", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
s.On("SWITCH").GoTo("screen2")
})
s.State("screen2", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
s.On("SWITCH").GoTo("screen1")
})
s.On("GO_IDLE").GoTo("idle")
}).
State("idle", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
s.On("WAKE").GoTo("app")
}).
Build()In this example, if the user navigates to screen2 and then goes idle, WAKE will return them to screen2 (not the initial screen1).
Sometimes a system is in multiple modes at once. A text editor might be Focused while also having Bold enabled.
Parallel states allow you to define regions that operate independently. Use actor.States() to see all active states.
s.State("active", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
s.Type(gstate.Parallel)
s.State("keyboard", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
s.Initial("caps_off")
s.State("caps_off", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
s.On("CAPS_LOCK").GoTo("caps_on")
})
s.State("caps_on", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
s.On("CAPS_LOCK").GoTo("caps_off")
})
})
s.State("mouse", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
s.Initial("not_clicked")
s.State("not_clicked", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
s.On("CLICK").GoTo("clicked")
})
s.State("clicked", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
s.On("RELEASE").GoTo("not_clicked")
})
})
})
// ...
fmt.Printf("Active States: %v\n", actor.States())
// Output: Active States: [active keyboard caps_off mouse not_clicked]Used for asynchronous work (like an API call). The service starts when you enter the state and is automatically cancelled (via context.Context) if you leave the state before it finishes.
s.Invoke(func(ctx context.Context, c MyCtx) error {
// This goroutine is managed by the Actor
return doExpensiveWork(ctx)
}, "onSuccessState", "onErrorState")Transitions that happen automatically after a duration.
s.State("loading", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
// If we are stuck here for 5 seconds, move to "error"
s.After(5 * time.Second).GoTo("error")
})Always transitions fire immediately if their Guard (a condition function) is met. They don't wait for an external event. This is useful for "decider" states.
s.State("check_balance", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
s.Always().
Guard(func(c MyCtx) bool { return c.Balance > 100 }).
GoTo("premium_user")
s.Always().GoTo("regular_user") // Fallback
})A Final state indicates the completion of its parent's process. Once entered, no further transitions are processed from that state.
s.State("done", func(s *gstate.StateBuilder[MyState, MyEvent, MyContext]) {
s.Type(gstate.Final)
})A Machine is a static blueprint. To actually run it, you create an Actor. The Actor holds the live state, processes events, and manages async services.
// Start with default options (mailbox size of 100)
actor := gstate.Start(machine, MyContext{Count: 0})
// Or start with custom options
actor := gstate.StartWithOptions(machine, MyContext{Count: 0}, gstate.Options{
MailboxSize: 500,
})actor.Send(EventIncrement)
actor.Send(EventStart)Events are queued in a channel-based mailbox and processed sequentially, ensuring state transitions are never concurrent.
// Get the deepest active leaf state
state := actor.State()
// Get ALL active states (useful for parallel states)
states := actor.States()
// Get a thread-safe copy of the context data
ctx := actor.Context()
// Get a full snapshot (active states, history, and context)
snap := actor.Snapshot()All read methods are thread-safe (protected by RWMutex).
actor.Stop()Stop() cancels all running invocations, stops all timers, and closes the mailbox. It is safe to call multiple times.
Snapshots allow you to serialize the full state of an Actor and restore it later. This is critical for long-running workflows that must survive process restarts.
// 1. Capture the current state
snapshot := actor.Snapshot()
// 2. Serialize to JSON (for storage in a database, file, etc.)
data, _ := json.MarshalIndent(snapshot, "", " ")
// 3. Later, deserialize and restore
var loaded gstate.Snapshot[MyState, MyContext]
json.Unmarshal(data, &loaded)
actor2 := gstate.Hydrate(machine, loaded)
// actor2 is now in exactly the same state as the originalA Snapshot contains:
Active []S— all currently active statesHistory map[S]S— the history map (parent → remembered child)Context C— the context data
Hydrate restores the actor state and restarts any background services (invocations and timers) for active states, without re-executing entry actions.
gstate uses a hybrid concurrency model to ensure safety and performance:
- Sequential Mailbox (Channels): All events sent via
actor.Send(event)are queued. A background goroutine processes them one by one, ensuring that state transitions and context updates are strictly sequential. - Thread-Safe Access (RWMutex): Methods like
actor.State(),actor.States(),actor.Context(), andactor.Snapshot()are safe to call concurrently. They use a read-lock to provide a consistent view of the actor. - Asynchronous Integrity:
InvokeandAfterrun in separate goroutines but their results are funneled back through the sequential logic to prevent data races on your Context.
gstate is based on the formalisms of Statecharts, which provide a rigorous way to model complex, event-driven systems.
- Statecharts: A Visual Formalism for Complex Systems: The original 1987 paper by David Harel that introduced the concept.
- W3C SCXML Specification: The standard for State Chart XML, which defines many of the behaviors (like parallel states and history) implemented in this library.
- XState Documentation: An excellent resource for learning statechart patterns (the library that inspired
gstate's API).
Check the examples/ directory for runnable code with deep commentary on every feature:
| Example | Feature |
|---|---|
| basics | States, events, transitions, entry/exit, assign |
| hierarchy | Nested states and event bubbling |
| parallel | Parallel regions and States() |
| history | Shallow and deep history |
| invoke | Async services with cancellation |
| delayed | Time-based transitions with After |
| agent | Complex workflow with guards, always, and invoke |
| persistence | Snapshot serialization and hydration |
This project is licensed under the MIT License - see the LICENSE file for details.