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:
- All child regions are activated simultaneously
- Each region enters its own
initialchild state - Events are delivered to every region
- Each region handles (or ignores) the event independently
active_state_idsshows one active state from each region
Note: Parallel regions don’t have an
initialflag on the parent — there’s no “first” child. All children start together. However, each region (which is a compound state) still needs its owninitialchild.
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.statetarget 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 withstrict_targets=Trueby 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. Passcreate_machine(config, strict_targets=False)to downgrade this to aDeprecationWarninginstead. See the JSON Config guide for the fullstrictTargetsreference.
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. Settinginitial=Trueon a parallel child will raise anInvalidConfigError.
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
QUITevent is on the parent parallel staterunning. It catches the event from any combination of region states and exits them all, transitioning togameOver.
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):
- every state any of them exits is exited (reverse document order),
- then each transition’s own actions run, region by region in document order,
- 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.