Hierarchical states let you model parent/child relationships where parent transitions apply to all child states, and parents choose which child state to enter by default. This reduces duplication and better represents real-world state structures.
- Why Use Hierarchical States
- Basic Parent-Child Relationship
- Parent Transitions
- Initial Substates
- Multiple Levels of Hierarchy
- Entry and Exit with Hierarchies
- Complete Example
Reduce duplication — Define common transitions once on the parent instead of on every child
Model real hierarchies — Match your domain's natural structure (authenticated user has multiple substates)
Cleaner configuration — Group related states under a parent
Reusable behavior — Parent actions apply to all children automatically
Define parent states and their children:
public enum ConnectionState
{
Disconnected,
Connected, // Parent
Idle, // Child of Connected
Transmitting // Child of Connected
}
public abstract record ConnectionTrigger
{
public sealed record Connect : ConnectionTrigger;
public sealed record Disconnect : ConnectionTrigger;
public sealed record StartTransmit : ConnectionTrigger;
public sealed record StopTransmit : ConnectionTrigger;
}
public abstract record ConnectionCommand
{
public sealed record OpenConnection : ConnectionCommand;
public sealed record CloseConnection : ConnectionCommand;
public sealed record Log(string Message) : ConnectionCommand;
}
var machine = StateMachine<ConnectionState, ConnectionTrigger, ConnectionCommand>.Create()
.StartWith(ConnectionState.Disconnected)
.For(ConnectionState.Connected)
.StartsWith(ConnectionState.Idle) // When entering Connected, start in Idle
.For(ConnectionState.Idle)
.SubStateOf(ConnectionState.Connected) // Declare parent relationship
.On<ConnectionTrigger.StartTransmit>()
.TransitionTo(ConnectionState.Transmitting)
.For(ConnectionState.Transmitting)
.SubStateOf(ConnectionState.Connected) // Also a child of Connected
.On<ConnectionTrigger.StopTransmit>()
.TransitionTo(ConnectionState.Idle)
.Build();Key points:
Connectedis the parent stateIdleandTransmittingare children (declared with.SubStateOf())- Parent chooses initial child with
.StartsWith() - When transitioning to
Connected, machine actually entersIdle
Transitions defined on the parent work from all child states:
var machine = StateMachine<ConnectionState, ConnectionTrigger, ConnectionCommand>.Create()
.StartWith(ConnectionState.Disconnected)
.For(ConnectionState.Disconnected)
.On<ConnectionTrigger.Connect>()
.Execute(() => new ConnectionCommand.OpenConnection())
.TransitionTo(ConnectionState.Connected) // Goes to Connected.Idle
.For(ConnectionState.Connected)
.StartsWith(ConnectionState.Idle)
.On<ConnectionTrigger.Disconnect>() // Parent transition
.Execute(() => new ConnectionCommand.CloseConnection())
.TransitionTo(ConnectionState.Disconnected)
.For(ConnectionState.Idle)
.SubStateOf(ConnectionState.Connected)
.On<ConnectionTrigger.StartTransmit>()
.TransitionTo(ConnectionState.Transmitting)
.For(ConnectionState.Transmitting)
.SubStateOf(ConnectionState.Connected)
.On<ConnectionTrigger.StopTransmit>()
.TransitionTo(ConnectionState.Idle)
.Build();
// Disconnect works from BOTH Idle and Transmitting
var (state1, cmds1) = machine.Fire(
new ConnectionTrigger.Disconnect(),
ConnectionState.Idle);
// state1 == ConnectionState.Disconnected ✅
var (state2, cmds2) = machine.Fire(
new ConnectionTrigger.Disconnect(),
ConnectionState.Transmitting);
// state2 == ConnectionState.Disconnected ✅How it works:
Disconnecttrigger is defined onConnectedparent- When in
Idle(child),Disconnecttrigger is still available - When in
Transmitting(child),Disconnecttrigger is still available - Don't need to define
Disconnecton each child
Use .StartsWith() to define which child state to enter:
public enum SessionState
{
Inactive,
Active, // Parent
Anonymous, // Child of Active
Authenticated // Child of Active
}
var machine = StateMachine<SessionState, SessionTrigger, SessionCommand>.Create()
.StartWith(SessionState.Inactive)
.For(SessionState.Active)
.StartsWith(SessionState.Anonymous) // Default child
.For(SessionState.Anonymous)
.SubStateOf(SessionState.Active)
.On<SessionTrigger.Login>()
.TransitionTo(SessionState.Authenticated)
.For(SessionState.Authenticated)
.SubStateOf(SessionState.Active)
.Build();
// When transitioning to Active, machine enters Anonymous
var (state, _) = machine.Fire(
new SessionTrigger.Start(),
SessionState.Inactive);
// Transition says "go to Active"
// Machine actually goes to Active.Anonymous (the initial substate)Without .StartsWith():
Trying to transition to a parent state without specifying the initial child will cause a validation error at build time.
When both parent and child handle the same trigger, child takes precedence:
var machine = StateMachine<State, Trigger, Command>.Create()
.For(State.Parent)
.StartsWith(State.Child1)
.On<Trigger.Action>()
.Execute(() => new Command.ParentAction())
.TransitionTo(State.SomewhereElse)
.For(State.Child1)
.SubStateOf(State.Parent)
.On<Trigger.Action>() // Child also handles Action
.Execute(() => new Command.ChildAction())
.TransitionTo(State.Child2)
.For(State.Child2)
.SubStateOf(State.Parent)
// Child2 doesn't handle Action, so parent's Action applies here
.Build();
// From Child1: child handler wins
var (state1, cmds1) = machine.Fire(new Trigger.Action(), State.Child1);
// cmds1 == [ChildAction()] ← Child handler used
// state1 == State.Child2
// From Child2: parent handler used
var (state2, cmds2) = machine.Fire(new Trigger.Action(), State.Child2);
// cmds2 == [ParentAction()] ← Parent handler used
// state2 == State.SomewhereElseRule: Child transitions override parent transitions for the same trigger.
You can nest multiple levels of substates:
public enum AppState
{
Offline,
Online, // Level 1
Authenticated, // Level 2 (child of Online)
Viewing, // Level 3 (child of Authenticated)
Editing // Level 3 (child of Authenticated)
}
var machine = StateMachine<AppState, AppTrigger, AppCommand>.Create()
.StartWith(AppState.Offline)
.For(AppState.Online)
.StartsWith(AppState.Authenticated)
.On<AppTrigger.NetworkLost>() // Applies to all descendants
.TransitionTo(AppState.Offline)
.For(AppState.Authenticated)
.SubStateOf(AppState.Online)
.StartsWith(AppState.Viewing)
.On<AppTrigger.Logout>() // Applies to Viewing and Editing
.TransitionTo(AppState.Online) // Back to Online (will enter Authenticated)
.For(AppState.Viewing)
.SubStateOf(AppState.Authenticated)
.On<AppTrigger.StartEdit>()
.TransitionTo(AppState.Editing)
.For(AppState.Editing)
.SubStateOf(AppState.Authenticated)
.On<AppTrigger.StopEdit>()
.TransitionTo(AppState.Viewing)
.Build();
// NetworkLost works from BOTH Viewing and Editing (grandchildren of Online)
var (state, _) = machine.Fire(new AppTrigger.NetworkLost(), AppState.Editing);
// state == AppState.Offline ✅
// Parent's parent transition still worksEntry and exit commands run when entering/leaving the actual (leaf) state, not the parent:
var machine = StateMachine<State, Trigger, Command>.Create()
.For(State.Parent)
.StartsWith(State.Child1)
.OnEntry(() => new Command.Log("Enter Parent")) // Runs when entering from outside hierarchy
.OnExit(() => new Command.Log("Exit Parent")) // Runs when leaving hierarchy
.For(State.Child1)
.SubStateOf(State.Parent)
.OnEntry(() => new Command.Log("Enter Child1"))
.OnExit(() => new Command.Log("Exit Child1"))
.On<Trigger.Switch>()
.TransitionTo(State.Child2)
.For(State.Child2)
.SubStateOf(State.Parent)
.OnEntry(() => new Command.Log("Enter Child2"))
.OnExit(() => new Command.Log("Exit Child2"))
.Build();
// Entering the hierarchy from outside
var (_, cmds1) = machine.Fire(new Trigger.GoToParent(), State.Outside);
// cmds1 == [
// Log("Enter Parent"), // Parent entry
// Log("Enter Child1") // Child entry (initial substate)
// ]
// Moving within the hierarchy (Child1 → Child2)
var (_, cmds2) = machine.Fire(new Trigger.Switch(), State.Child1);
// cmds2 == [
// Log("Exit Child1"), // Exit old child
// Log("Enter Child2") // Enter new child
// ]
// Parent entry/exit NOT called (staying within Parent)
// Leaving the hierarchy
var (_, cmds3) = machine.Fire(new Trigger.Leave(), State.Child2);
// cmds3 == [
// Log("Exit Child2"), // Exit child
// Log("Exit Parent") // Exit parent
// ]Rules:
- Entering hierarchy = parent entry + initial child entry
- Moving between siblings = old child exit + new child entry (parent entry/exit skip)
- Leaving hierarchy = child exit + parent exit
A media player with hierarchical playback states:
public enum PlayerState
{
Stopped,
Playing, // Parent
PlayingAudio, // Child of Playing
PlayingVideo, // Child of Playing
Paused, // Parent
PausedAudio, // Child of Paused
PausedVideo // Child of Paused
}
public abstract record PlayerTrigger
{
public sealed record Play : PlayerTrigger;
public sealed record Pause : PlayerTrigger;
public sealed record Stop : PlayerTrigger;
public sealed record SwitchToAudio : PlayerTrigger;
public sealed record SwitchToVideo : PlayerTrigger;
}
public sealed record PlayerData(
string CurrentTrack,
MediaType Type,
int Position);
public enum MediaType { Audio, Video }
public abstract record PlayerCommand
{
public sealed record StartPlayback(string Track, MediaType Type) : PlayerCommand;
public sealed record PausePlayback : PlayerCommand;
public sealed record ResumePlayback : PlayerCommand;
public sealed record StopPlayback : PlayerCommand;
public sealed record SwitchMedia(MediaType Type) : PlayerCommand;
}
var machine = StateMachine<PlayerState, PlayerTrigger, PlayerData, PlayerCommand>.Create()
.StartWith(PlayerState.Stopped)
.For(PlayerState.Stopped)
.On<PlayerTrigger.Play>()
.Execute(data => new PlayerCommand.StartPlayback(data.CurrentTrack, data.Type))
.TransitionTo(PlayerState.Playing)
// Playing parent state
.For(PlayerState.Playing)
.StartsWith(PlayerState.PlayingAudio)
// Parent transitions - apply to BOTH PlayingAudio and PlayingVideo
.On<PlayerTrigger.Pause>()
.Execute(() => new PlayerCommand.PausePlayback())
.TransitionTo(PlayerState.Paused)
.On<PlayerTrigger.Stop>()
.Execute(() => new PlayerCommand.StopPlayback())
.TransitionTo(PlayerState.Stopped)
// Playing children
.For(PlayerState.PlayingAudio)
.SubStateOf(PlayerState.Playing)
.On<PlayerTrigger.SwitchToVideo>()
.ModifyData(data => data with { Type = MediaType.Video })
.Execute(() => new PlayerCommand.SwitchMedia(MediaType.Video))
.TransitionTo(PlayerState.PlayingVideo)
.For(PlayerState.PlayingVideo)
.SubStateOf(PlayerState.Playing)
.On<PlayerTrigger.SwitchToAudio>()
.ModifyData(data => data with { Type = MediaType.Audio })
.Execute(() => new PlayerCommand.SwitchMedia(MediaType.Audio))
.TransitionTo(PlayerState.PlayingAudio)
// Paused parent state
.For(PlayerState.Paused)
.StartsWith(PlayerState.PausedAudio)
// Parent transitions - apply to BOTH PausedAudio and PausedVideo
.On<PlayerTrigger.Play>()
.Execute(() => new PlayerCommand.ResumePlayback())
.TransitionTo(PlayerState.Playing)
.On<PlayerTrigger.Stop>()
.Execute(() => new PlayerCommand.StopPlayback())
.TransitionTo(PlayerState.Stopped)
// Paused children
.For(PlayerState.PausedAudio)
.SubStateOf(PlayerState.Paused)
.On<PlayerTrigger.SwitchToVideo>()
.ModifyData(data => data with { Type = MediaType.Video })
.TransitionTo(PlayerState.PausedVideo)
.For(PlayerState.PausedVideo)
.SubStateOf(PlayerState.Paused)
.On<PlayerTrigger.SwitchToAudio>()
.ModifyData(data => data with { Type = MediaType.Audio })
.TransitionTo(PlayerState.PausedAudio)
.Build();
// Usage scenarios
var data = new PlayerData(
CurrentTrack: "song.mp3",
Type: MediaType.Audio,
Position: 0);
// Scenario 1: Play audio
var (state1, data1, cmds1) = machine.Fire(
new PlayerTrigger.Play(),
PlayerState.Stopped,
data);
// state1 == PlayerState.PlayingAudio (Playing's initial substate)
// cmds1 == [StartPlayback("song.mp3", Audio)]
// Scenario 2: Pause (parent transition works from child state)
var (state2, data2, cmds2) = machine.Fire(
new PlayerTrigger.Pause(),
state1,
data1);
// state2 == PlayerState.PausedAudio (Paused's initial substate)
// cmds2 == [PausePlayback()]
// Pause trigger defined on Playing parent, works from PlayingAudio child
// Scenario 3: Switch to video while paused
var (state3, data3, cmds3) = machine.Fire(
new PlayerTrigger.SwitchToVideo(),
state2,
data2);
// state3 == PlayerState.PausedVideo
// data3.Type == MediaType.Video
// Scenario 4: Resume (parent transition)
var (state4, data4, cmds4) = machine.Fire(
new PlayerTrigger.Play(),
state3,
data3);
// state4 == PlayerState.PlayingVideo (Playing's default is Audio, but we're coming from PausedVideo)
// Wait, this is a problem! Let me fix this...Note: The above example has a subtle issue—when resuming from PausedVideo, we want to go to PlayingVideo, not PlayingAudio. This requires a more sophisticated design. Here's the fix:
// Better approach: Map paused states to their playing counterparts
.For(PlayerState.PausedAudio)
.SubStateOf(PlayerState.Paused)
.On<PlayerTrigger.Play>() // Override parent
.Execute(() => new PlayerCommand.ResumePlayback())
.TransitionTo(PlayerState.PlayingAudio)
// other transitions...
.For(PlayerState.PausedVideo)
.SubStateOf(PlayerState.Paused)
.On<PlayerTrigger.Play>() // Override parent
.Execute(() => new PlayerCommand.ResumePlayback())
.TransitionTo(PlayerState.PlayingVideo)
// other transitions...What's happening:
- Two hierarchies: Playing (Audio/Video) and Paused (Audio/Video)
- Common actions (Stop, Pause, Play) defined on parents
- Media switching defined on children
- Children override parent's Play to resume to correct playing state
- Greatly reduces duplication—Stop works from all 4 substates without repeating
✅ Use hierarchies for related states
Group states that share common transitions or behavior.
✅ Define common transitions on parent
Avoid repeating the same transition on every child.
✅ Always specify initial substate
Use .StartsWith() on every parent.
✅ Let children override when needed
Child transitions take precedence over parent transitions.
❌ Don't overuse hierarchies
Not every group of states needs to be hierarchical. Use when it reduces duplication.
❌ Avoid deep nesting
Two levels is usually enough. Deeper hierarchies get complex.
.For(State.Authenticated)
.StartsWith(State.ViewingProfile)
.On<Trigger.Logout>() // Works from all authenticated substates
.TransitionTo(State.Unauthenticated).For(State.Connected)
.StartsWith(State.Idle)
.On<Trigger.Disconnect>() // Works from all connection substates
.TransitionTo(State.Disconnected).For(State.Processing)
.StartsWith(State.Step1)
.On<Trigger.Error>() // Works from all processing steps
.TransitionTo(State.Failed)- Use with Entry/Exit Commands to understand how entry/exit work in hierarchies
- Combine with Guards for conditional parent transitions
- See Internal Transitions which children can have without affecting parent