Parallel States

Concurrent state regions — multiple independent processes running simultaneously.

Parallel states model concurrent activity — multiple independent processes running at the same time within the same machine. Instead of being in one child state at a time (like compound states), a parallel state is in all of its child regions simultaneously.

🎛️ What Are Parallel States?

In a compound state, the machine is in exactly one child at a time. In a parallel state, the machine is in one state from every child region at the same time. Each region operates independently — events are delivered to all regions, and each region handles them according to its own transitions.

stateDiagram-v2
    state "playing (parallel — all three regions active at once)" as playing {
        state video {
            [*] --> loading
            loading --> showing : READY
            showing --> buffering : STALL
            buffering --> showing : READY
        }
        --
        state audio {
            [*] --> muted
            muted --> playing_audio : UNMUTE
            playing_audio --> muted : MUTE
        }
        --
        state controls {
            [*] --> visible
            visible --> hidden : IDLE
            hidden --> visible : MOVE
        }
    }

🎬 JSON Example: Media Player

{
  "id": "mediaPlayer",
  "initial": "playing",
  "states": {
    "playing": {
      "type": "parallel",
      "states": {
        "video": {
          "initial": "loading",
          "states": {
            "loading": {
              "on": {
                "VIDEO_LOADED": "showing"
              }
            },
            "showing": {
              "on": {
                "BUFFER": "buffering"
              }
            },
            "buffering": {
              "on": {
                "VIDEO_LOADED": "showing"
              }
            }
          }
        },
        "audio": {
          "initial": "muted",
          "states": {
            "muted": {
              "on": {
                "UNMUTE": "playing"
              }
            },
            "playing": {
              "on": {
                "MUTE": "muted"
              }
            }
          }
        },
        "controls": {
          "initial": "visible",
          "states": {
            "visible": {
              "after": {
                "3000": "hidden"
              }
            },
            "hidden": {
              "on": {
                "MOUSE_MOVE": "visible"
              }
            }
          }
        }
      }
    }
  }
}

Running it:

from xstate_statemachine import create_machine, SyncInterpreter

config = {
    "id": "mediaPlayer",
    "initial": "playing",
    "states": {
        "playing": {
            "type": "parallel",
            "states": {
                "video": {
                    "initial": "loading",
                    "states": {
                        "loading": {
                            "on": {
                                "VIDEO_LOADED": "showing",
                            },
                        },
                        "showing": {
                            "on": {
                                "BUFFER": "buffering",
                            },
                        },
                        "buffering": {
                            "on": {
                                "VIDEO_LOADED": "showing",
                            },
                        },
                    },
                },
                "audio": {
                    "initial": "muted",
                    "states": {
                        "muted": {
                            "on": {
                                "UNMUTE": "playing",
                            },
                        },
                        "playing": {
                            "on": {
                                "MUTE": "muted",
                            },
                        },
                    },
                },
                "controls": {
                    "initial": "visible",
                    "states": {
                        "visible": {
                            "after": {
                                "3000": "hidden",
                            },
                        },
                        "hidden": {
                            "on": {
                                "MOUSE_MOVE": "visible",
                            },
                        },
                    },
                },
            },
        },
    },
}

machine = create_machine(config)
interp = SyncInterpreter(machine).start()

print(interp.active_state_ids)
# {'mediaPlayer.playing.video.loading',
#  'mediaPlayer.playing.audio.muted',
#  'mediaPlayer.playing.controls.visible'}
# Note: active_state_ids is a set, so the printed order is not guaranteed
# and may differ from run to run — compare with `==` against a set literal
# (or use `sorted(...)`) rather than relying on printed order.

# Events target specific regions independently
interp.send("VIDEO_LOADED")
print(interp.active_state_ids)
# {'mediaPlayer.playing.video.showing',
#  'mediaPlayer.playing.audio.muted',
#  'mediaPlayer.playing.controls.visible'}

interp.send("UNMUTE")
print(interp.active_state_ids)
# {'mediaPlayer.playing.video.showing',
#  'mediaPlayer.playing.audio.playing',
#  'mediaPlayer.playing.controls.visible'}

interp.stop()

All three regions (video, audio, controls) are active at the same time. Sending VIDEO_LOADED only affects the video region — the other regions stay in their current states.

How It Works

When the machine enters a parallel state:

  1. All child regions are activated simultaneously
  2. Each region enters its own initial child state
  3. Events are delivered to every region
  4. Each region handles (or ignores) the event independently
  5. active_state_ids shows one active state from each region

Note: Parallel regions don’t have an initial flag on the parent — there’s no “first” child. All children start together. However, each region (which is a compound state) still needs its own initial child.

Note: A bare target: "someState" only resolves as a sibling within the same region — it cannot reach into another parallel region by name. Use an absolute #machine.path.to.state target to move a state in a different region (that resets the source region too — see Transitions Between Regions). See Hierarchical States — Bare Targets Are Sibling-Only. Since 0.8.0, create_machine() runs with strict_targets=True by default, so an unresolvable cross-region bare target is rejected at build time (a clear error when the machine is created) rather than surfacing later as a silent no-op at runtime. Pass create_machine(config, strict_targets=False) to downgrade this to a DeprecationWarning instead. See the JSON Config guide for the full strictTargets reference.

Creating Parallel States

JSON: "type": "parallel"

{
  "myParallelState": {
    "type": "parallel",
    "states": {
      "regionA": {
        "initial": "idle",
        "states": {
          "idle": {
            "on": {
              "START_A": "running"
            }
          },
          "running": {
            "on": {
              "STOP_A": "idle"
            }
          }
        }
      },
      "regionB": {
        "initial": "idle",
        "states": {
          "idle": {
            "on": {
              "START_B": "running"
            }
          },
          "running": {
            "on": {
              "STOP_B": "idle"
            }
          }
        }
      }
    }
  }
}

Pythonic: parallel=True Parameter

from xstate_statemachine import State, StateMachine, SyncInterpreter


class PlayerMachine(StateMachine):
    machine_id = "player"

    playing = State(
        "playing",
        initial=True,
        parallel=True,
        states=[
            State(
                "video",
                states=[
                    State(
                        "loading",
                        initial=True,
                        on={
                            "VIDEO_LOADED": "showing",
                        },
                    ),
                    State("showing"),
                ],
            ),
            State(
                "audio",
                states=[
                    State(
                        "muted",
                        initial=True,
                        on={
                            "UNMUTE": "playing",
                        },
                    ),
                    State(
                        "playing",
                        on={
                            "MUTE": "muted",
                        },
                    ),
                ],
            ),
        ],
    )


machine = PlayerMachine.create_machine()
interp = SyncInterpreter(machine).start()

print(interp.active_state_ids)
# {'player.playing.video.loading', 'player.playing.audio.muted'}

interp.send("VIDEO_LOADED")
interp.send("UNMUTE")
print(interp.active_state_ids)
# {'player.playing.video.showing', 'player.playing.audio.playing'}

interp.stop()

Warning: Children of a parallel state should not have initial=True. All children of a parallel parent are entered simultaneously. Setting initial=True on a parallel child will raise an InvalidConfigError.

MachineBuilder: .child_states(parent, parallel=True)

from xstate_statemachine import MachineBuilder, SyncInterpreter

machine = (
    MachineBuilder("player")
    .state("playing", initial=True)
    .child_states(
        "playing",
        parallel=True,
        states={
            "video": {
                "initial": "loading",
                "states": {
                    "loading": {
                        "on": {
                            "VIDEO_LOADED": "showing",
                        },
                    },
                    "showing": {},
                },
            },
            "audio": {
                "initial": "muted",
                "states": {
                    "muted": {
                        "on": {
                            "UNMUTE": "playing",
                        },
                    },
                    "playing": {
                        "on": {
                            "MUTE": "muted",
                        },
                    },
                },
            },
        },
    )
    .build()
)

interp = SyncInterpreter(machine).start()

print(interp.active_state_ids)
# {'player.playing.video.loading', 'player.playing.audio.muted'}

interp.stop()

Functional API

from xstate_statemachine import State, build_machine, SyncInterpreter

playing = State(
    "playing",
    initial=True,
    parallel=True,
    states=[
        State(
            "video",
            states=[
                State(
                    "loading",
                    initial=True,
                    on={
                        "VIDEO_LOADED": "showing",
                    },
                ),
                State("showing"),
            ],
        ),
        State(
            "audio",
            states=[
                State(
                    "muted",
                    initial=True,
                    on={
                        "UNMUTE": "playing",
                    },
                ),
                State(
                    "playing",
                    on={
                        "MUTE": "muted",
                    },
                ),
            ],
        ),
    ],
)

machine = build_machine(id="player", states=[playing])
interp = SyncInterpreter(machine).start()

print(interp.active_state_ids)
# {'player.playing.video.loading', 'player.playing.audio.muted'}

interp.stop()

Multiple Regions Example (3+ Regions)

A game state with independent physics, AI, and UI regions:

from xstate_statemachine import create_machine, SyncInterpreter

config = {
    "id": "game",
    "initial": "running",
    "states": {
        "running": {
            "type": "parallel",
            "states": {
                "physics": {
                    "initial": "simulating",
                    "states": {
                        "simulating": {
                            "on": {
                                "PAUSE_PHYSICS": "paused",
                            },
                        },
                        "paused": {
                            "on": {
                                "RESUME_PHYSICS": "simulating",
                            },
                        },
                    },
                },
                "ai": {
                    "initial": "thinking",
                    "states": {
                        "thinking": {
                            "on": {
                                "AI_DECIDED": "executing",
                            },
                        },
                        "executing": {
                            "on": {
                                "AI_DONE": "thinking",
                            },
                        },
                    },
                },
                "rendering": {
                    "initial": "drawing",
                    "states": {
                        "drawing": {
                            "on": {
                                "FRAME_DONE": "waiting",
                            },
                        },
                        "waiting": {
                            "on": {
                                "VSYNC": "drawing",
                            },
                        },
                    },
                },
            },
            "on": {
                "QUIT": "gameOver",
            },
        },
        "gameOver": {
            "type": "final",
        },
    },
}

machine = create_machine(config)
interp = SyncInterpreter(machine).start()

print(interp.active_state_ids)
# {'game.running.physics.simulating',
#  'game.running.ai.thinking',
#  'game.running.rendering.drawing'}

# Each region transitions independently
interp.send("PAUSE_PHYSICS")
interp.send("AI_DECIDED")

print(interp.active_state_ids)
# {'game.running.physics.paused',
#  'game.running.ai.executing',
#  'game.running.rendering.drawing'}

# Parent transition exits ALL regions at once
interp.send("QUIT")
print(interp.active_state_ids)
# {'game.gameOver'}

interp.stop()

Tip: The QUIT event is on the parent parallel state running. It catches the event from any combination of region states and exits them all, transitioning to gameOver.

Parallel with Actions

Entry and exit actions fire when parallel regions are entered or exited:

from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic

config = {
    "id": "withActions",
    "initial": "active",
    "states": {
        "active": {
            "type": "parallel",
            "entry": "logParallelEntry",
            "exit": "logParallelExit",
            "states": {
                "regionA": {
                    "initial": "idle",
                    "entry": "initRegionA",
                    "states": {
                        "idle": {
                            "on": {
                                "GO_A": "running",
                            },
                        },
                        "running": {},
                    },
                },
                "regionB": {
                    "initial": "idle",
                    "entry": "initRegionB",
                    "states": {
                        "idle": {
                            "on": {
                                "GO_B": "running",
                            },
                        },
                        "running": {},
                    },
                },
            },
            "on": {
                "SHUTDOWN": "stopped",
            },
        },
        "stopped": {
            "type": "final",
        },
    },
}


class ParallelLogic(MachineLogic):
    def log_parallel_entry(self, interpreter, context, event, action_def):
        print("Entered parallel state")

    def log_parallel_exit(self, interpreter, context, event, action_def):
        print("Exiting parallel state — all regions stopping")

    def init_region_a(self, interpreter, context, event, action_def):
        print("Region A initialized")

    def init_region_b(self, interpreter, context, event, action_def):
        print("Region B initialized")


machine = create_machine(config, logic=ParallelLogic())
interp = SyncInterpreter(machine).start()
# Output:
# Entered parallel state
# Region A initialized
# Region B initialized

interp.send("SHUTDOWN")
# Output:
# Exiting parallel state — all regions stopping

interp.stop()

Entry and Exit Order

Regions are entered and exited one whole region at a time, in the order the chart declares them — the XState v5 / SCXML rule:

  • Entry runs in document order: the parallel state, then the first declared region and everything inside it, then the next region. A transition that targets a state deep inside one region does not change this: the targeted region is entered in its declared position, not last.
  • Exit runs in reverse document order: descendants before their ancestors, and the region declared last exits first.
from xstate_statemachine import SyncInterpreter, create_machine
from xstate_statemachine.testing_utils import stub_logic

config = {
    "id": "app",
    "initial": "idle",
    "states": {
        "idle": {
            "on": {
                "OPEN": "#app.session.audio.muted",
            },
        },
        "session": {
            "type": "parallel",
            "entry": "enterSession",
            "exit": "exitSession",
            "on": {
                "CLOSE": "idle",
            },
            "states": {
                "audio": {
                    "initial": "playing",
                    "entry": "enterAudio",
                    "exit": "exitAudio",
                    "states": {
                        "playing": {},
                        "muted": {
                            "entry": "enterMuted",
                            "exit": "exitMuted",
                        },
                    },
                },
                "video": {
                    "initial": "shown",
                    "entry": "enterVideo",
                    "exit": "exitVideo",
                    "states": {
                        "shown": {
                            "entry": "enterShown",
                            "exit": "exitShown",
                        },
                    },
                },
            },
        },
    },
}

ran = []
machine = create_machine(config, logic=stub_logic(config, ran=ran))
interp = SyncInterpreter(machine).start()

interp.send("OPEN")
assert ran == [
    "enterSession",
    "enterAudio",
    "enterMuted",
    "enterVideo",
    "enterShown",
]

ran.clear()
interp.send("CLOSE")
assert ran == [
    "exitShown",
    "exitVideo",
    "exitMuted",
    "exitAudio",
    "exitSession",
]

Changed in 0.12.0: entry used to run the regions not on the transition’s path first and the targeted region last, and exit ran every leaf before every region, ordered by how the state ids sort. If one region’s actions must run before another’s, declare that region earlier (entry) or later (exit).

Transitions Between Regions

A transition whose target lies in a sibling region of the same parallel state (an absolute #id target, since a bare name only resolves inside its own region) has the parallel state as its transition domain. Every region exits, and every region is entered again: the targeted region through the target, the others through their initial state. The parallel state itself is neither exited nor re-entered.

from xstate_statemachine import SyncInterpreter, create_machine
from xstate_statemachine.testing_utils import stub_logic

config = {
    "id": "m",
    "initial": "p",
    "states": {
        "p": {
            "type": "parallel",
            "states": {
                "left": {
                    "initial": "a",
                    "states": {
                        "a": {
                            "on": {
                                "GO": "b",
                            },
                        },
                        "b": {
                            "on": {
                                "JUMP": "#m.p.right.d",
                            },
                        },
                    },
                },
                "right": {
                    "initial": "c",
                    "states": {
                        "c": {},
                        "d": {},
                    },
                },
            },
        },
    },
}

machine = create_machine(config, logic=stub_logic(config))
interp = SyncInterpreter(machine).start()
interp.send("GO")
interp.send("JUMP")
# `left` was reset to its initial state `a`; `right` moved to `d`.
assert interp.current_state_ids == {"m.p.left.a", "m.p.right.d"}

To move only the other region, handle the event inside that region instead — every region receives every event, so right.c: {"on": {"JUMP": "d"}} leaves left untouched. (Before 0.12.0 a cross-region target exited and re-entered only the targeted region.)

When Several Regions Handle the Same Event

Every region that handles an event contributes one transition — from the deepest active state in that region that handles it. The parallel state’s own handler for the event (or an ancestor’s) is used only when no region handled it. The transitions taken for one event form one microstep (XState v5 / SCXML):

  1. every state any of them exits is exited (reverse document order),
  2. then each transition’s own actions run, region by region in document order,
  3. then every state any of them enters is entered (document order).
from xstate_statemachine import SyncInterpreter, create_machine
from xstate_statemachine.testing_utils import stub_logic

config = {
    "id": "m",
    "initial": "p",
    "states": {
        "p": {
            "type": "parallel",
            "states": {
                "left": {
                    "initial": "a",
                    "states": {
                        "a": {
                            "exit": "exitA",
                            "on": {
                                "E": {
                                    "target": "b",
                                    "actions": "left",
                                },
                            },
                        },
                        "b": {
                            "entry": "enterB",
                        },
                    },
                },
                "right": {
                    "initial": "c",
                    "states": {
                        "c": {
                            "exit": "exitC",
                            "on": {
                                "E": {
                                    "target": "d",
                                    "actions": "right",
                                },
                            },
                        },
                        "d": {
                            "entry": "enterD",
                        },
                    },
                },
            },
        },
    },
}

ran = []
machine = create_machine(config, logic=stub_logic(config, ran=ran))
interp = SyncInterpreter(machine).start()
interp.send("E")
assert ran == ["exitC", "exitA", "left", "right", "enterB", "enterD"]

Two of those transitions conflict when their exit sets overlap — for example when one region’s transition targets a state in another region (its domain is the parallel state, so it exits every region). Only one of them is taken: the one selected first in document order, unless the later one’s source is a descendant of the earlier one’s source, in which case the more deeply nested transition wins. The other transition is dropped, not run afterwards on whatever state the winner left behind. For always transitions “selected first” means the region whose active state was entered first (see Guards → Guard Evaluation Order).

Changed in 0.12.0: each selected transition used to run as its own exit → actions → entry sequence, one region after another (exitA, left, enterB, exitC, right, enterD), and a transition whose source an earlier one had exited and re-entered still ran.

Entering Several Regions at Once (Multi-Target)

target may be a list of states that lie in different regions of one parallel state. Each region then enters its target instead of its initial state, in document order:

{
  "idle": {
    "on": {
      "RESUME": {
        "target": ["#app.session.audio.muted", "#app.session.video.shown"]
      }
    }
  }
}

Any other list — two targets in the same compound region, a state together with one of its own descendants, the same state twice, or targets that are not under one common parallel state — is rejected by create_machine() with an InvalidConfigError. A one-element list is the same as a plain string target. transition.target_strs lists every target; transition.target_str (and diagram exports) show the first.

Parallel with Guards

Guards work within parallel regions just like in any other state:

{
  "dashboard": {
    "type": "parallel",
    "states": {
      "dataFeed": {
        "initial": "live",
        "states": {
          "live": {
            "on": {
              "REFRESH": [
                {
                  "target": "refreshing",
                  "guard": "isOnline"
                },
                {
                  "actions": "showOfflineMessage"
                }
              ]
            }
          },
          "refreshing": {
            "on": {
              "DATA_LOADED": "live"
            }
          }
        }
      },
      "alerts": {
        "initial": "checking",
        "states": {
          "checking": {
            "on": {
              "ALERTS_LOADED": "displaying"
            }
          },
          "displaying": {}
        }
      }
    }
  }
}

Parallel Containing Nested States

Parallel regions can themselves contain compound (nested) states — any combination of hierarchy is valid:

from xstate_statemachine import create_machine, SyncInterpreter

config = {
    "id": "nestedParallel",
    "initial": "app",
    "states": {
        "app": {
            "type": "parallel",
            "states": {
                "navigation": {
                    "initial": "home",
                    "states": {
                        "home": {
                            "initial": "feed",
                            "states": {
                                "feed": {
                                    "on": {
                                        "VIEW_POST": "detail",
                                    },
                                },
                                "detail": {
                                    "on": {
                                        "BACK": "feed",
                                    },
                                },
                            },
                        },
                        "search": {
                            "on": {
                                "GO_HOME": "home",
                            },
                        },
                    },
                    "on": {
                        "SEARCH": "search",
                    },
                },
                "notifications": {
                    "initial": "idle",
                    "states": {
                        "idle": {
                            "on": {
                                "NEW_NOTIFICATION": "showing",
                            },
                        },
                        "showing": {
                            "after": {
                                "5000": "idle",
                            },
                        },
                    },
                },
            },
        },
    },
}

machine = create_machine(config)
interp = SyncInterpreter(machine).start()

print(interp.active_state_ids)
# {'nestedParallel.app.navigation.home.feed',
#  'nestedParallel.app.notifications.idle'}

interp.send("VIEW_POST")
print(interp.active_state_ids)
# {'nestedParallel.app.navigation.home.detail',
#  'nestedParallel.app.notifications.idle'}

# Notification arrives — only affects the notifications region
interp.send("NEW_NOTIFICATION")
print(interp.active_state_ids)
# {'nestedParallel.app.navigation.home.detail',
#  'nestedParallel.app.notifications.showing'}

interp.stop()

active_state_ids with Parallel

When parallel states are active, active_state_ids contains one leaf state from each active region:

interp = SyncInterpreter(machine).start()

# Flat machine: always one active state
# {'myMachine.someState'}

# Parallel machine: one per region
# {'myMachine.parallel.regionA.childA',
#  'myMachine.parallel.regionB.childB',
#  'myMachine.parallel.regionC.childC'}

You can check whether a specific region is in a specific state:

active = interp.active_state_ids

# Is video currently showing?
video_showing = "mediaPlayer.playing.video.showing" in active

# Is any region in an error state?
has_error = any("error" in sid for sid in active)

# Which regions are active?
regions = {sid.split(".")[2] for sid in active if sid.count(".") >= 2}

interpreter.value with Parallel

active_state_ids is convenient but requires string-splitting to answer “which region is in which state?”. Since 0.8.0, interpreter.value gives you the hierarchical value representation directly: a dict with one key per active region, mapping to that region’s own value (a leaf state name, or a nested dict for a further-nested compound/parallel state):

interp = SyncInterpreter(machine).start()

print(interp.value)
# {'playing': {'video': 'loading', 'audio': 'muted'}}

matches() also accepts a partial value dict, so you can check one region without building the whole structure:

interp.matches(
    {
        "playing": {
            "video": "loading",
        },
    }
)  # True — ignores the audio region

This is generally a nicer way to introspect parallel regions than manually parsing active_state_ids strings.

Complete Example: Dashboard with Notifications, Data Feed, and User Activity

from xstate_statemachine import (
    create_machine,
    SyncInterpreter,
    MachineLogic,
    service,
)

config = {
    "id": "dashboard",
    "initial": "active",
    "context": {
        "notifications": [],
        "feedData": [],
        "lastActivity": None,
    },
    "states": {
        "active": {
            "type": "parallel",
            "states": {
                "notifications": {
                    "initial": "polling",
                    "states": {
                        "polling": {
                            "invoke": {
                                "src": "fetchNotifications",
                                "onDone": {
                                    "target": "displaying",
                                    "actions": "storeNotifications",
                                },
                                "onError": "error",
                            },
                        },
                        "displaying": {
                            "after": {
                                "30000": "polling",
                            },
                            "on": {
                                "MARK_READ": {
                                    "actions": "markAsRead",
                                },
                                "DISMISS_ALL": {
                                    "actions": "clearNotifications",
                                },
                            },
                        },
                        "error": {
                            "after": {
                                "60000": "polling",
                            },
                        },
                    },
                },
                "dataFeed": {
                    "initial": "loading",
                    "states": {
                        "loading": {
                            "invoke": {
                                "src": "fetchFeedData",
                                "onDone": {
                                    "target": "live",
                                    "actions": "storeFeedData",
                                },
                                "onError": "stale",
                            },
                        },
                        "live": {
                            "after": {
                                "15000": "loading",
                            },
                            "on": {
                                "FORCE_REFRESH": "loading",
                            },
                        },
                        "stale": {
                            "after": {
                                "30000": "loading",
                            },
                            "on": {
                                "FORCE_REFRESH": "loading",
                            },
                        },
                    },
                },
                "userActivity": {
                    "initial": "active",
                    "states": {
                        "active": {
                            "after": {
                                "300000": "idle",
                            },
                            "on": {
                                "USER_ACTION": {
                                    "target": "active",
                                    "reenter": True,
                                    "actions": "recordActivity",
                                },
                            },
                        },
                        "idle": {
                            "entry": "dimDisplay",
                            "on": {
                                "USER_ACTION": {
                                    "target": "active",
                                    "actions": "recordActivity",
                                },
                            },
                        },
                    },
                },
            },
            "on": {
                "LOGOUT": "loggedOut",
            },
        },
        "loggedOut": {
            "type": "final",
        },
    },
}


class DashboardLogic(MachineLogic):
    # As of 0.8.0, `@action` / `@guard` / `@service` markers win over
    # arity-based auto-registration in `MachineLogic` subclasses; a 3-arg
    # method with no marker is ambiguous (service, or a 3-arg action) and
    # emits a `UserWarning`. `fetch_notifications` and `fetch_feed_data` are
    # invoked services, so they're decorated with `@service` explicitly.
    @service
    def fetch_notifications(self, interpreter, context, event):
        return [
            {
                "id": 1,
                "msg": "New message",
            },
            {
                "id": 2,
                "msg": "Update",
            },
        ]

    def store_notifications(self, interpreter, context, event, action_def):
        context["notifications"] = event.data
        print(f"Loaded {len(event.data)} notifications")

    def mark_as_read(self, interpreter, context, event, action_def):
        nid = event.data.get("id")
        context["notifications"] = [
            n for n in context["notifications"] if n.get("id") != nid
        ]

    def clear_notifications(self, interpreter, context, event, action_def):
        context["notifications"] = []

    @service
    def fetch_feed_data(self, interpreter, context, event):
        return [
            {
                "metric": "cpu",
                "value": 42,
            },
            {
                "metric": "mem",
                "value": 78,
            },
        ]

    def store_feed_data(self, interpreter, context, event, action_def):
        context["feedData"] = event.data
        print(f"Feed updated with {len(event.data)} entries")

    def record_activity(self, interpreter, context, event, action_def):
        import time

        context["lastActivity"] = time.time()

    def dim_display(self, interpreter, context, event, action_def):
        print("User idle — dimming display")


machine = create_machine(config, logic=DashboardLogic())
interp = SyncInterpreter(machine).start()

# All three regions start simultaneously
print(interp.active_state_ids)
# Shows one state from each of: notifications, dataFeed, userActivity

# pending_invocations() lists invokes in the active configuration that have
# NO live service — it's empty here (both services are actually running),
# but becomes populated after `from_snapshot()` restores a state whose
# `invoke` says work is in flight while nothing is really running it. Use
# it as a health check after a restore, then re-drive what it reports.
print(interp.pending_invocations())
# []

# User action resets the idle timer in userActivity (reenter: True),
# without affecting notifications or dataFeed
interp.send("USER_ACTION")

# Force refresh the data feed — only affects dataFeed region
interp.send("FORCE_REFRESH")

# Logout exits ALL regions at once
interp.send("LOGOUT")
print(interp.active_state_ids)
# {'dashboard.loggedOut'}

interp.stop()

This dashboard runs three completely independent processes:

  • Notifications: polls every 30s, displays results, retries on error every 60s
  • Data Feed: refreshes every 15s, falls back to stale mode on error
  • User Activity: tracks idle time, dims the display after 5 minutes of inactivity

All three regions run concurrently, and a single LOGOUT event from the parent cleanly shuts them all down.