Skip to content

State Management

CascadeUI uses a Redux-inspired unidirectional data flow. All state lives in a single store, updated through dispatched actions and immutable reducers. See Core Concepts -- Data Flow for the architecture diagram, and Core Concepts -- State Topology for the full state dict structure.


The State Store

The StateStore is a singleton:

from cascadeui import get_store

store = get_store()  # Always returns the same instance

Actions and Dispatch

Actions are plain dicts with type, payload, source, and timestamp. Dispatch them through the store or a view:

# From the store
await store.dispatch("MY_ACTION", {"key": "value"})

# From a view (adds self.id as source)
await self.dispatch("MY_ACTION", {"key": "value"})

Subscriber failures don't propagate

dispatch() wraps subscriber callbacks in a safe handler that catches and logs exceptions. Post-dispatch logic runs regardless:

await self.dispatch("GAME_FINISHED", payload)
await self._cleanup_attached_children()  # Always runs

Built-in Actions

CascadeUI dispatches these automatically. For payload shapes, reducer behavior, and middleware interaction, see the Built-in Actions API reference.

Action When
VIEW_CREATED View registered with the store
VIEW_UPDATED View state modified
VIEW_DESTROYED View cleaned up (exit or timeout)
SESSION_CREATED New user session begins
SESSION_UPDATED Session data updated via update_session()
NAVIGATION_PUSH View pushed onto nav stack
NAVIGATION_POP View popped from nav stack
NAVIGATION_REPLACE View replaced (one-way)
SCOPED_UPDATE Scoped state updated
COMPONENT_INTERACTION Button or select clicked
MODAL_SUBMITTED Modal form submitted
PERSISTENT_VIEW_REGISTERED PersistentView sent and tracked
PERSISTENT_VIEW_UNREGISTERED PersistentView removed
UNDO / REDO Undo/redo performed
BATCH_COMPLETE Batch of actions finishes

Custom Reducers

Most state-management needs are covered by Application Slots and Scoped State below -- the convenience layer that dispatch_scoped writes through. Reach for @cascade_reducer when the state shape genuinely outgrows the slot model: cross-view aggregations, complex transitions, derived data that depends on multiple slots, or custom action types that need their own dispatch grammar.

Reducers transform state in response to actions. @cascade_reducer handles deep-copying automatically -- mutate and return directly:

from cascadeui import cascade_reducer

@cascade_reducer("SCORE_UPDATED")
async def score_reducer(action, state):
    scores = state.setdefault("application", {}).setdefault("scores", {})
    scores[action["payload"]["user_id"]] = action["payload"]["score"]
    return state

No copy.deepcopy needed

@cascade_reducer passes a deep copy. Mutate directly and return.

Don't hold a reference to the snapshot across await

The dict passed to a reducer is a fresh deep copy that lives only for that one call. Capturing the snapshot in a closure or a class attribute and then reading it later returns a stale value -- the store has already moved on to the next snapshot. Read the live state via store.state (or a view's state_store.state) instead, and re-read after every await boundary if values may have changed in flight.

@cascade_reducer("ITEM_ADDED")
async def add_item(action, state):
    items = state.setdefault("application", {}).setdefault("items", [])
    items.append(action["payload"])
    return state  # OK -- the store keeps this and discards the input snapshot

# Outside reducers, never do this:
snapshot = store.state                 # stale immediately after any dispatch
await some_other_dispatch()
snapshot["application"]["items"]       # reads the OLD copy, not current

Subscribers

Subscribe to state changes with optional filtering:

# All actions
store.subscribe("my-listener", my_callback)

# Specific action types only
store.subscribe("my-listener", my_callback,
    action_filter={"SCORE_UPDATED", "GAME_ENDED"})

Selectors

Subscribe to a specific state slice -- the callback only fires when the selected value changes:

store.subscribe("score-watcher", on_scores_changed,
    selector=lambda state: state.get("scores", {}))

Views use selectors via state_selector():

class ScoreView(StatefulLayoutView):
    subscribed_actions = {"SCORE_UPDATED"}

    def state_selector(self, state):
        return state.get("scores", {}).get(self.persistence_key)

Views unsubscribe automatically on exit or timeout.

Read the state argument, not self.state

Every selector receives the candidate next state as its state argument. Reading self.state (or self.state_store.state) instead returns the current store state, which during a dispatch is whatever existed before the pending action was applied. That breaks subscription change-detection silently -- the selector returns the same value on every call, and the view's on_state_changed never fires.

Correct: return state.get("scores", {}) Bug: return self.state.get("scores", {})

The same rule applies to @computed selector functions and to StateStore.get_scoped_from(state, ...) calls made from reducers. get_scoped_from is the staticmethod specifically designed to read from the deep-copied state handed to a reducer, without reaching back through self.state_store.get_scoped(...).

Concurrent Updates

When multiple dispatches target the same subscriber simultaneously (e.g. two players clicking buttons at the same time), CascadeUI coalesces the notifications automatically. The first notification runs on_state_changed normally; any notifications that arrive while it is running set a pending flag and return immediately. After the first completes, it re-runs once with the latest store state to capture all pending changes.

This prevents concurrent build_ui() and message.edit() calls from racing on the same view. Single-user views are unaffected - the lock is never contended when only one dispatch runs at a time.

CascadeUI also preserves interaction routing during rebuilds. When build_ui() calls clear_items(), old components stay routable in discord.py's internal dispatch table until message.edit() completes the re-registration. Pending interactions from other users are handled normally instead of being discarded.


Application Slots

An application slot is a named bucket under state["application"][name]. Every feature that wants a place in the state tree -- a game's fleet positions, a dashboard's visit counts, a wizard's in-flight step -- gets one slot and owns the shape inside it. Slot ownership is claimed by the first write and inherited by everyone who reaches for the same name afterward.

Three helpers cover the read/write split so selectors never accidentally mutate authoritative state and reducers never hand-roll the walk:

Helper Purpose Where it's safe
access_slot(state, name, key=None, *, default_factory=None, persistent=False) Write/init. Auto-vivifies state["application"][name][key] and returns the stored value. Reducers (deep-copied state), seed_initial_state hook.
read_slot(state, name, *path, default=None) Variadic pure read. Walks arbitrary depth via dict.get chains, never mutates. state_selector methods, @computed selectors, anywhere holding live store state by reference.
slot_property(name, slot=..., key=..., default=...) Descriptor for the canonical three-level shape (application[slot][keyed][field]) read at attribute-access time. Class body of any view; reads self.state_store.state.
from cascadeui import (
    access_slot, read_slot, slot_property, cascade_reducer, StatefulLayoutView,
)


@cascade_reducer("SHIP_MOVED")
def reduce_ship_moved(action, state):
    # access_slot walks state["application"]["battleship"][user_id],
    # seeding {} on first access. Safe to mutate -- state is deep-copied.
    bs = access_slot(state, "battleship", action["payload"]["user_id"])
    bs["ships"] = action["payload"]["ships"]
    return state


class FleetView(StatefulLayoutView):
    state_scope = "user"

    # Canonical three-level read: application["battleship"][user_id]["ships"]
    ships = slot_property(
        "ships", slot="battleship", key=lambda self: self.user_id, default=[]
    )

    def state_selector(self, state):
        # read_slot is variadic -- pass any depth of path after the slot name.
        return read_slot(state, "battleship", self.user_id, "ships", default=[])

Slots are in-memory by default. Passing persistent=True to access_slot (or declaring persistent_slots = ("battleship",) on the view class) registers the slot for write-through persistence -- every subsequent write to that name is fanned out to disk by PersistenceMiddleware. See the persistence guide for the full opt-in model.

For paths deeper than three levels, the slot_property descriptor caps out; declare a plain @property and call read_slot inside it:

class StatsView(StatefulLayoutView):
    @property
    def combat_wins(self):
        return read_slot(
            self.state_store.state,
            "stats", self.guild_id, self.user_id, "combat", "wins",
            default=0,
        )

Scoped State

Isolate state per user, guild, or globally. Scoped data is stored under state["application"]["scoped"], which means it shares the application namespace's persistence plumbing -- opt a scoped slot in to disk with persistent_slots = ("scoped",).

Setup

Set state_scope on the view class:

class SettingsView(StatefulLayoutView):
    state_scope = "user"  # Each user gets independent state
state_scope Key Use case
"user" User ID Per-user preferences
"guild" Guild ID Per-server configuration
"user_guild" User + Guild ID Per-user-per-server isolation
"global" (none) Global shared namespace
None (default) N/A No scoping -- dispatch_scoped unavailable

state_scope vs instance_scope

Both accept the same string values but govern different subsystems. state_scope = where data is stored. instance_scope = how instances are counted. See Views -- Instance Management.

Reading

# Generic property (returns the view's own scope slice)
my_data = self.scoped_state

# Named accessors for hub views reading multiple scopes
user_prefs = self.user_scoped_state()
guild_config = self.guild_scoped_state()
per_server = self.user_guild_scoped_state()
global_settings = self.global_scoped_state()

# Override identifiers to read other users' data
other_user = self.user_scoped_state(user_id=other_id)

Writing

await self.dispatch_scoped({"clicks": 5, "name": "Alice"})

Scoped state merges with existing data.

Scoped data is in-memory by default

Scoped state is a Redux data-organization pattern, not a persistence default. Writes land in state["application"]["scoped"] (or the named bucket declared on scoped_slot) and stay in-memory unless the slot is opted in. Two ways to persist:

  • Class-level: persistent_slots = ("scoped",) on the view class, or persistent_slots = ("my_slot",) paired with scoped_slot = "my_slot".
  • Setup-level: SlotPolicy(persistent=True) (plus optional ttl_days=N) in the application config passed to PersistenceMiddleware.

Named scoped slots

Pass a scoped_slot class attribute to route a view's scoped reads and writes into a named subsystem bucket instead of the default "scoped" catch-all:

class BattleshipView(StatefulLayoutView):
    state_scope = "user_guild"
    scoped_slot = "battleship_stats"
    persistent_slots = ("battleship_stats",)

Writes dispatched via self.dispatch_scoped(...) now land under state["application"]["battleship_stats"][<scope_key>]. Multiple subsystems co-exist cleanly under application without collision.

Scoped family helpers

StateStore exposes a small family for working with scoped data consistently from views, reducers, and selectors:

Helper When to use
self.scoped_state Property -- the current view's own scope slice.
self.user_scoped_state(user_id=None) Read the "user" scope slice; defaults to this view's user_id.
self.guild_scoped_state(guild_id=None) Read the "guild" scope slice; defaults to this view's guild_id.
self.user_guild_scoped_state(user_id=None, guild_id=None) Read the "user_guild" composite scope slice.
self.global_scoped_state() Read the "global" scope slice.
self.dispatch_scoped(data) Write-through from a view -- merges into the scope slot.
self.dispatch_scoped_as(scope, data, **ids) Write-through from a view with an explicit scope + identifiers.
store.get_scoped(scope, **ids) Read from a live store (subscribers, devtools).
StateStore.get_scoped_from(state, scope, **ids) Staticmethod -- read from the state arg handed to a reducer or @computed selector.
store.iter_scoped(scope, slot_name="scoped") Iterate every scope bucket under a slot.
StateStore.merge_scoped(state, scope, data, *, slot_name="scoped", subkey=None, **ids) Reducer-side writer -- mutates the deep-copied state in place.

Cross-View Reactivity

dispatch_scoped() fires SCOPED_UPDATE, which other views don't subscribe to by default. For live cross-view updates, dispatch a named action with a custom reducer:

# This does NOT notify other views:
await self.dispatch_scoped({"theme": "dark"})

# This notifies all subscribers of "SETTINGS_UPDATED":
await self.dispatch("SETTINGS_UPDATED", {
    "scope_key": f"user:{self.user_id}",
    "changes": {"theme": "dark"},
})
Method Other views react? Creates undo snapshots?
dispatch_scoped() No Yes
dispatch("NAMED_ACTION") Yes Yes

dispatch_scoped and undo

dispatch_scoped() creates undo snapshots for views with enable_undo = True -- scoped data lives under state["application"], which the undo middleware snapshots. The limitation is cross-view reactivity: SCOPED_UPDATE is not in any view's default subscribed_actions, so other views don't rebuild automatically. Named actions close that gap.


Session Data

Session data is metadata shared across all views in a push/pop navigation chain. Views that share a session_id (all pushed/popped views inherit the parent's session) share the same data dict. Unlike scoped state, session data is ephemeral - it lives for the duration of the session and is not persisted across restarts.

Independent invocations do not share a session by default

Every view invocation gets its own session_id because the auto-derived identity includes a per-instance UUID suffix. Two users opening the same view class -- or the same user opening it twice -- produce distinct sessions with independent shared_data. Push/pop chains still stay on one session because _navigate_to forwards session_id explicitly. Views that want repeat-open continuity (undo history surviving close-and-reopen, for instance) set session_continuity: ClassVar[bool] = True on the class; see Five Pillars -- Session continuity for the full contract.

Reading

config = self.shared_data  # dict (empty if no data set)
lang = self.shared_data.get("lang", "en")

Writing

await self.update_session(lang="fr", difficulty="hard")

update_session shallow-merges into the existing session data. Dispatches SESSION_UPDATED, so views subscribing to that action are notified. Session data changes are included in undo snapshots when enable_undo = True.

When to Use Session Data

Session data fills the gap between scoped state and instance attributes:

Storage Scope Persists? Shared across views?
Instance attributes Single view No No
Session data Push/pop chain No Yes
Scoped state User/guild/global Yes Yes

Typical use cases:

  • Wizard configuration -- a multi-step wizard stores the chosen template or mode in session data so every step can read it without passing kwargs
  • Game settings -- difficulty, turn timer, or house rules shared across all views in a game session
  • Navigation breadcrumbs -- tracking which category was last visited in a settings hub, so the back button returns to the right place

Why Session Data Is Not Persisted

Session data is tied to session bookkeeping, and sessions are inherently transient: a session_id identifies a live navigation window, not a durable container. When the last view in a session exits, the session record (and its shared_data) is removed by the reducer. Across a bot restart, no view is attached to any session, so every session is effectively orphaned. Persisting shared_data would write entries keyed to session_id values that no future view will ever reattach to -- the data would live on as tombstones.

If you want cross-view data that does survive restarts, reach for scoped state instead. Scoped slots are keyed by user_id, guild_id, or explicit identifiers -- all stable across restarts -- and opt into persistence via persistent_slots = ("scoped",). Two panels that need to coordinate across restarts subscribe to the same scoped slot; a long wizard stores its in-flight step in a scoped slot keyed to the user. The session layer keeps its single responsibility (grouping live views) and the persistence layer owns durable data with stable identity.

Need Where it lives
In-flight wizard step that survives restart access_slot(state, "wizard_state", user_id, persistent=True)
Cross-panel settings shared per-guild dispatch_scoped(scope="guild", ...) with persistent_slots = ("scoped",)
Temporary config passed between wizard steps in one sitting shared_data via update_session()
Navigation breadcrumb for the current session shared_data via update_session()

Persistent views reattach with fresh session_id values by design -- a restart is the end of liveness, and the new panel is a new session that happens to use the same Discord message. Any data the panel cares about lives in the scoped or application namespace, not in session bookkeeping.

Cross-View Reactivity

Other views can subscribe to SESSION_UPDATED to react to session data changes:

class SubPageView(StatefulLayoutView):
    subscribed_actions = {"SESSION_UPDATED"}

    def state_selector(self, state):
        session = state.get("sessions", {}).get(self.session_id, {})
        return session.get("shared_data", {}).get("difficulty")

    def build_ui(self):
        difficulty = self.shared_data.get("difficulty", "normal")
        # Rebuild UI based on the shared session config

Action Batching

Dispatch multiple actions atomically. Subscribers fire once after all actions complete:

async with self.batch() as b:
    await b.dispatch("VOTE_CAST", {"user_id": user_id, "delta": 1})
    await b.dispatch("VOTE_LOG", {"entry": "User voted +1"})
# Single notification cycle fires here

Inside the block, each dispatch runs through middleware and the reducer immediately -- state is current at every line, so later dispatches can read what earlier ones wrote. Only subscriber notifications are deferred until the block exits, where one synthetic BATCH_COMPLETE action fires the fan-out.

When to batch

Reach for batch() when a single user interaction triggers multiple state changes that belong together logically. Each dispatch outside a batch fires the full subscriber list, which means every connected view calls on_state_changed() and queues a message.edit(). Six dispatches means six rebuilds and six edits; one batch() collapses that to one.

Typical scenarios:

  • Reset-all / apply-all: a "reset to defaults" button that writes six settings at once. See examples/v2_settings.py for the idiom.
  • Multi-step transitions: a game "start round" action that clears the previous turn, rolls a new seed, and resets per-player state.
  • Wizard commit: a final "submit" step that dispatches one action per collected field, then a terminal WIZARD_COMPLETED.

batch() does not help with a single slow dispatch. If one reducer or one subscriber is the bottleneck, batch() changes nothing -- use the Performance tab to identify the phase, then tighten the selector or cache with @computed.

Transitivity and helpers

Every helper that eventually calls store.dispatch() participates in the outer batch: update_session(), dispatch_scoped(), and the internal _register_state() used by send(). Nested batch() blocks absorb into the outermost batch, so composing batched operations is safe:

async with self.batch():
    await self.dispatch("ROUND_STARTED")
    await self.update_session(seed=new_seed)  # joins the batch
    async with self.batch():                  # absorbs, no inner fan-out
        for pid in player_ids:
            await self.dispatch_scoped({"last_move": None})
# One BATCH_COMPLETE fires here

Exception handling

If the async with block raises, every action queued during the batch is dropped from the outgoing notification -- subscribers never see the partial sequence. Reducers have already run (state is mutated in place as each dispatch returns), so batch() is not a transaction; it is a notification gate. Catch and inspect state explicitly if you need rollback semantics.

The library already batches its own pipelines

send(), push() / pop() / replace(), and cascade cleanup on attached children already wrap their internal dispatches in a single batch. Callers don't need to wrap await view.send() in a batch themselves -- the navigation and lifecycle sequences produce one notification cycle each regardless.

Batch and undo

With UndoMiddleware active, all actions in a batch produce a single undo entry. Reverting "reset all settings" restores every slice in one UNDO.


Event Hooks

React to state lifecycle events without subscribing:

async def on_interaction(action, state):
    print(f"Component {action['payload'].get('component_id')} clicked")

store.on("component_interaction", on_interaction)
store.off("component_interaction", on_interaction)

Hook names map to action types: view_createdVIEW_CREATED, component_interactionCOMPONENT_INTERACTION, etc.

Hooks vs subscribers vs middleware

  • Middleware -- before the reducer, can modify/block actions
  • Subscribers -- after the reducer, filtered by action type + selector
  • Hooks -- after subscribers, read-only (logging, analytics)

Computed Values

Register memoized derived state that any view can read without recalculating. The @computed decorator combines a selector (picks a state slice to watch) with a compute function (transforms that slice). The result is cached and only recomputed when the selector output changes.

from cascadeui import computed, get_store

@computed(selector=lambda s: s.get("application", {}).get("votes", {}))
def vote_totals(votes):
    return {lang: len(voters) for lang, voters in votes.items()}

# Any view reads the same cached result
store = get_store()
totals = store.computed["vote_totals"]

How It Works

  1. On access (store.computed["name"]), the selector runs against current state
  2. If the selector output matches the last-seen value, the cached result returns
  3. If the selector output changed, the compute function runs and the result is cached

Recomputation is lazy -- it only happens when a view reads the value, not on every dispatch.

Computed vs state_selector()

@computed state_selector()
Scope Global, shared across all views Per-view instance
Purpose Derive and cache a transformed value Detect whether a view's relevant state changed
Access store.computed["name"] Automatic (drives on_state_changed)
Caching One cache per registered name One cache per subscriber

Use @computed when multiple views need the same derived value (totals, rankings, aggregates). Use state_selector() when a single view needs to filter which state changes trigger its rebuild.

Invalidation

Force recomputation on next access:

store.computed["vote_totals"].invalidate()

This is rarely needed -- the selector-based check handles most cases automatically.

Full Example

See v2_computed.py for a complete poll example with computed totals and leader detection.


Undo/Redo

Enable undo/redo per view:

from cascadeui import UndoMiddleware, get_store

store = get_store()
store.add_middleware(UndoMiddleware(store))

class EditableView(StatefulLayoutView):
    enable_undo = True
    undo_limit = 20     # Max snapshots (default)

    async def undo_action(self, interaction):
        await self.undo()

    async def redo_action(self, interaction):
        await self.redo()

Snapshots capture both state["application"] and the session's data dict. This means dispatch_scoped() changes, update_session() changes, and custom reducer changes all round-trip through undo/redo. Internal lifecycle actions are excluded from undo tracking. New actions after an undo clear the redo stack (standard semantics).


Action History

The store keeps a history of dispatched actions for debugging:

history = store.action_history  # List of recent actions

The DevTools Inspector visualizes this history on the History tab.