Skip to content

No Data Builder

github-actions[bot] edited this page Feb 17, 2026 · 1 revision

When your state machine doesn't need to track data, use the no-data builder to keep your configuration simpler. You get all the same features—guards, commands, hierarchies—without the data type parameter.

Table of Contents

  1. Why Use No-Data Builder
  2. Basic No-Data Machine
  3. Features Available
  4. When to Use Data vs No-Data
  5. Converting Between Data and No-Data
  6. Complete Example

Why Use No-Data Builder

Simpler types — Three type parameters instead of four
Less boilerplate — No need to define and pass data records
Clearer intent — Signals that state alone drives behavior
Same features — Guards, hierarchies, entry/exit all still work


Basic No-Data Machine

Compare the two approaches:

With Data (4 type parameters)

public sealed record LightData(int UsageCount);

var machine = StateMachine<LightState, LightTrigger, LightData, LightCommand>
    .Create()
    .StartWith(LightState.Off)
    .For(LightState.Off)
        .On<LightTrigger.Toggle>()
            .TransitionTo(LightState.On)
    .Build();

// Must pass data when firing
var data = new LightData(UsageCount: 0);
var (newState, newData, commands) = machine.Fire(
    new LightTrigger.Toggle(),
    LightState.Off,
    data);

Without Data (3 type parameters)

var machine = StateMachine<LightState, LightTrigger, LightCommand>
    .Create()
    .StartWith(LightState.Off)
    .For(LightState.Off)
        .On<LightTrigger.Toggle>()
            .TransitionTo(LightState.On)
    .Build();

// No data to pass
var (newState, commands) = machine.Fire(
    new LightTrigger.Toggle(),
    LightState.Off);
// Simpler signature! ✅

Key difference: Fire() doesn't take or return data.


Features Available

All core features work with no-data machines:

✅ Transitions

var machine = StateMachine<State, Trigger, Command>.Create()
    .For(State.Start)
        .On<Trigger.Next>()
            .TransitionTo(State.End)
    .Build();

✅ Execute Steps

var machine = StateMachine<State, Trigger, Command>.Create()
    .For(State.Active)
        .On<Trigger.Action>()
            .Execute(() => new Command.DoWork())
            .Execute(() => new Command.Log("Work done"))
    .Build();

✅ Entry and Exit Commands

var machine = StateMachine<State, Trigger, Command>.Create()
    .For(State.Processing)
        .OnEntry(() => new Command.StartTimer())
        .OnExit(() => new Command.StopTimer())
    .Build();

✅ Guards (state-based)

var machine = StateMachine<State, Trigger, Command>.Create()
    .For(State.Gateway)
        .On<Trigger.Route>()
            .Guard(state => state == State.Gateway)  // Can check state
            .TransitionTo(State.PathA)
    .Build();

✅ Hierarchical States

var machine = StateMachine<State, Trigger, Command>.Create()
    .For(State.Parent)
        .StartsWith(State.Child1)
        .On<Trigger.Exit>()
            .TransitionTo(State.Outside)
    .For(State.Child1)
        .SubStateOf(State.Parent)
    .Build();

✅ Internal Transitions

var machine = StateMachine<State, Trigger, Command>.Create()
    .For(State.Running)
        .On<Trigger.Tick>()
            .Execute(() => new Command.RecordTick())
            // No TransitionTo = internal transition
    .Build();

✅ Immediate Transitions

var machine = StateMachine<State, Trigger, Command>.Create()
    .For(State.Starting)
        .Immediately()
            .TransitionTo(State.Ready)
            .Done()
    .Build();

❌ ModifyData

Not available—there's no data to modify!


When to Use Data vs No-Data

Use No-Data when:

✅ State alone captures all information
✅ No need to track counters, IDs, timestamps, etc.
✅ Simpler configuration is valuable
✅ Commands carry all necessary context

Examples:

  • Simple on/off switches
  • Basic workflow states (draft → review → approved)
  • Connection states (connected/disconnected)
  • UI navigation (menu → settings → game)

Use Data when:

✅ Need to track evolving information
✅ Guards depend on data values
✅ Commands need data context
✅ State + data together define the "state"

Examples:

  • Shopping cart (need items, totals)
  • User session (need user ID, timestamps)
  • Download progress (need byte counts)
  • Game state (need score, lives, level)

Guard Signatures in No-Data Machines

Guards can still use state and trigger:

public abstract record RouteTrigger
{
    public sealed record Go(string Destination) : RouteTrigger;
}

var machine = StateMachine<State, RouteTrigger, Command>.Create()
    .For(State.Gateway)
        .On<RouteTrigger.Go>()
            .Guard(trigger => trigger.Destination == "home")
            .TransitionTo(State.Home)
        .On<RouteTrigger.Go>()
            .Guard(trigger => trigger.Destination == "settings")
            .TransitionTo(State.Settings)
    .Build();

Available guard signatures:

  • Guard(state => ...)
  • Guard(trigger => ...)
  • Guard((state, trigger) => ...)

Not available: Any signature with data => parameter.


Execute Signatures in No-Data Machines

Execute steps can use state and trigger:

var machine = StateMachine<State, Trigger, Command>.Create()
    .For(State.Active)
        .On<Trigger.Action>()
            .Execute(() => new Command.Log("Simple"))                    // No params
            .Execute(state => new Command.Log($"State: {state}"))        // State only
            .Execute(trigger => new Command.Log($"Trigger: {trigger}"))  // Trigger only
            .Execute((state, trigger) => 
                new Command.Log($"{state} -> {trigger}"))                // Both
    .Build();

Not available: Any signature with data => parameter.


Converting Between Data and No-Data

Adding Data Later

If you start with no-data and need to add data:

// Before (no-data)
var machine = StateMachine<State, Trigger, Command>.Create()
    .For(State.Active)
        .On<Trigger.Action>()
            .Execute(() => new Command.DoWork())
    .Build();

// After (with data)
public sealed record StateData(int Count);

var machine = StateMachine<State, Trigger, StateData, Command>.Create()
    .For(State.Active)
        .On<Trigger.Action>()
            .ModifyData(data => data with { Count = data.Count + 1 })
            .Execute(data => new Command.DoWork(data.Count))
    .Build();

Migration cost: Moderate. Need to:

  1. Define data record
  2. Update type parameters
  3. Update Fire() calls to pass data
  4. Optionally use data in guards/execute

Removing Data

If you have data but don't need it:

// Before (with data)
public sealed record StateData(int Count);

var machine = StateMachine<State, Trigger, StateData, Command>.Create()
    .For(State.Active)
        .On<Trigger.Action>()
            .Execute(() => new Command.DoWork())  // Not using data!
    .Build();

// After (no-data)
var machine = StateMachine<State, Trigger, Command>.Create()
    .For(State.Active)
        .On<Trigger.Action>()
            .Execute(() => new Command.DoWork())
    .Build();

When safe: If no transitions use data (no ModifyData, guards don't check data, execute doesn't use data).


Complete Example

A traffic light state machine without data:

public enum TrafficLightState
{
    Red,
    Yellow,
    Green
}

public abstract record TrafficLightTrigger
{
    public sealed record TimerExpired : TrafficLightTrigger;
    public sealed record Emergency : TrafficLightTrigger;
    public sealed record EmergencyClear : TrafficLightTrigger;
}

public abstract record TrafficLightCommand
{
    public sealed record SetLight(TrafficLightState Color) : TrafficLightCommand;
    public sealed record StartTimer(int Seconds) : TrafficLightCommand;
    public sealed record FlashRed : TrafficLightCommand;
    public sealed record StopFlashing : TrafficLightCommand;
    public sealed record Log(string Message) : TrafficLightCommand;
}

var machine = StateMachine<TrafficLightState, TrafficLightTrigger, TrafficLightCommand>
    .Create()
    .StartWith(TrafficLightState.Red)
    
    .For(TrafficLightState.Red)
        .OnEntry(() => new TrafficLightCommand.SetLight(TrafficLightState.Red))
        .OnEntry(() => new TrafficLightCommand.StartTimer(30))
        .OnExit(() => new TrafficLightCommand.Log("Leaving Red"))
        .On<TrafficLightTrigger.TimerExpired>()
            .Execute(() => new TrafficLightCommand.Log("Red -> Green"))
            .TransitionTo(TrafficLightState.Green)
        .On<TrafficLightTrigger.Emergency>()
            .Execute(() => new TrafficLightCommand.FlashRed())
            .TransitionTo(TrafficLightState.Red)  // Self-transition triggers entry/exit
    
    .For(TrafficLightState.Green)
        .OnEntry(() => new TrafficLightCommand.SetLight(TrafficLightState.Green))
        .OnEntry(() => new TrafficLightCommand.StartTimer(45))
        .OnExit(() => new TrafficLightCommand.Log("Leaving Green"))
        .On<TrafficLightTrigger.TimerExpired>()
            .Execute(() => new TrafficLightCommand.Log("Green -> Yellow"))
            .TransitionTo(TrafficLightState.Yellow)
        .On<TrafficLightTrigger.Emergency>()
            .Execute(() => new TrafficLightCommand.FlashRed())
            .TransitionTo(TrafficLightState.Red)
    
    .For(TrafficLightState.Yellow)
        .OnEntry(() => new TrafficLightCommand.SetLight(TrafficLightState.Yellow))
        .OnEntry(() => new TrafficLightCommand.StartTimer(5))
        .OnExit(() => new TrafficLightCommand.Log("Leaving Yellow"))
        .On<TrafficLightTrigger.TimerExpired>()
            .Execute(() => new TrafficLightCommand.Log("Yellow -> Red"))
            .TransitionTo(TrafficLightState.Red)
        .On<TrafficLightTrigger.Emergency>()
            .Execute(() => new TrafficLightCommand.FlashRed())
            .TransitionTo(TrafficLightState.Red)
    
    .Build();

// Usage

// Normal operation
var (state1, cmds1) = machine.Fire(
    new TrafficLightTrigger.TimerExpired(),
    TrafficLightState.Red);
// state1 == TrafficLightState.Green
// cmds1 == [
//   Log("Leaving Red"),
//   SetLight(Green),
//   StartTimer(45),
//   Log("Red -> Green")
// ]

// Emergency override
var (state2, cmds2) = machine.Fire(
    new TrafficLightTrigger.Emergency(),
    state1);
// state2 == TrafficLightState.Red
// cmds2 == [
//   Log("Leaving Green"),
//   Log("Leaving Red"),      // Exit old Red (self-transition)
//   SetLight(Red),           // Entry new Red
//   StartTimer(30),
//   FlashRed()
// ]

// Timer continues after emergency
var (state3, cmds3) = machine.Fire(
    new TrafficLightTrigger.TimerExpired(),
    state2);
// state3 == TrafficLightState.Green

What's happening:

  1. No data needed—state alone defines behavior
  2. Entry commands set the light color and timer
  3. All transitions handled by timer or emergency
  4. Self-transition on emergency triggers entry/exit
  5. Simple, clean configuration without data clutter

Best Practices

✅ Start with no-data
Default to no-data unless you know you need data.

✅ Use commands to carry context
Instead of storing in data, pass information through commands.

✅ Use triggers to carry information
Trigger properties can carry one-time information.

✅ Consider external state
Sometimes data lives outside the state machine (database, cache, etc.).

❌ Don't force no-data
If you need to track evolving information, use data.

❌ Don't duplicate data in commands
If you're passing the same information in every command, it's probably data.


Common Patterns

Simple workflow

StateMachine<WorkflowState, WorkflowTrigger, WorkflowCommand>.Create()
    .For(State.Draft)
        .On<Trigger.Submit>()
            .TransitionTo(State.Review)

Connection state

StateMachine<ConnectionState, ConnectionTrigger, ConnectionCommand>.Create()
    .For(State.Disconnected)
        .On<Trigger.Connect>()
            .Execute(() => new Command.OpenSocket())
            .TransitionTo(State.Connected)

UI navigation

StateMachine<Screen, NavigationTrigger, NavigationCommand>.Create()
    .For(Screen.MainMenu)
        .On<Trigger.SelectSettings>()
            .Execute(() => new Command.ShowSettings())
            .TransitionTo(Screen.Settings)

Next Steps

  • Compare with State Data to understand when data is useful
  • See Guards for state and trigger-based guards
  • Learn about Execute Steps for emitting commands without data

Clone this wiki locally