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. 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()

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",
                                    "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,
# 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.