Skip to content

API: Built-in Actions

CascadeUI dispatches these actions internally to manage the state tree. They are handled by built-in reducers and cannot be overridden with @cascade_reducer. User-defined actions use separate type strings and coexist without conflict.

Every action is a dict with four top-level keys:

{
    "type": "VIEW_CREATED",       # Action type constant
    "payload": { ... },           # Action-specific data
    "source": "abc123",           # View ID of the dispatcher (or None)
    "timestamp": "2026-01-01T12:00:00.000000+00:00",  # ISO 8601 UTC at dispatch
}

For the quick-reference table, see State Management -- Built-in Actions.


Lifecycle

VIEW_CREATED

Dispatched by _register_state() during send(). Creates the view's entry in state["views"] and associates it with its session.

Payload:

Key Type Description
view_id str Unique view instance ID
view_type str Class name (e.g. "SettingsView")
user_id int \| None Owner's Discord user ID
session_id str \| None Session this view belongs to
guild_id int \| None Guild the view was created in
props dict Extra properties passed at creation

State change: Writes to state["views"][view_id] and appends view_id to state["sessions"][session_id]["members"]. The view record carries message_id and channel_id fields, but they arrive later via VIEW_UPDATED once the message exists; they are not part of this payload.


VIEW_UPDATED

Dispatched by dispatch() when updating view-specific state. Merges payload fields into the existing view entry.

Payload:

Key Type Description
view_id str Target view ID
* any Additional fields merged into the view's state

State change: Updates state["views"][view_id] with all payload keys (except view_id itself) and sets updated_at.


VIEW_DESTROYED

Dispatched by exit(), on_timeout(), and _navigate_to(). Removes the view from the state tree and cleans up associated data.

Payload:

Key Type Description
view_id str View being destroyed
session_id str Optional. A session to drop when the view never reached the state and the session has no member left

State change:

  • Deletes state["views"][view_id]
  • Removes component interaction entries owned by this view
  • Removes modal submission entries owned by this view
  • Removes view_id from its session's view list
  • Deletes the session entirely once its member list is empty
  • With session_id and no row for the view (a send rolled back before its VIEW_CREATED landed), deletes that session when it has no member

Push and pop keep the session alive by ordering rather than by a flag: _navigate_to registers the destination view in state before dispatching the source view's VIEW_DESTROYED, so the member list is never empty mid-transition.


SESSION_CREATED

Dispatched by _register_state() alongside VIEW_CREATED. Creates a new session entry if one does not already exist.

Payload:

Key Type Description
session_id str Session identifier. Default shape is <module.QualName>:user_<id>:<8hex> (the 8-hex suffix isolates repeat opens); views that set session_continuity = True drop the suffix and produce <module.QualName>:user_<id>
user_id int \| None Session owner
guild_id int \| None Guild the session belongs to
shared_data dict Initial session data

State change: Writes to state["sessions"][session_id] with empty members and history lists, and shared_data seeded from the payload (empty when none was supplied). Skips if the session already exists (idempotent for push/pop chains that share a session).


SESSION_UPDATED

Dispatched when session-level shared data changes. Use update_session() on any view to dispatch this action with the correct session_id:

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

Read the result with the shared_data property:

lang = self.shared_data.get("lang", "en")

Session data is shared across all views in the same push/pop chain (they inherit the parent's session_id). Unlike scoped state, session data is ephemeral and not persisted across restarts.

Payload:

Key Type Description
session_id str Target session
shared_data dict Fields to shallow-merge into the session's shared_data dict

State change: Shallow-merges payload["shared_data"] into state["sessions"][session_id]["shared_data"] and sets updated_at. No-op if the session does not exist.


Dispatched by push(). Records the current view on the session's nav stack so pop() can reconstruct it later.

Payload:

Key Type Description
session_id str Session owning the nav stack
class_name str Fully qualified class name of the view being pushed from
module str \| None Module path for dynamic import on pop
kwargs dict Constructor kwargs snapshot (captured by __init_subclass__)
state_snapshot any Optional state to restore on pop

State change: None. The reducer returns state untouched. The navigation stack is view-local, transferred between view objects by _navigate_to, so sessions carry no nav_stack key. The action still fires so middleware, subscribers, and hooks observe the transition.


Dispatched by pop(). Removes the top entry from the nav stack.

Payload:

Key Type Description
session_id str Session owning the nav stack

State change: None, for the same reason as NAVIGATION_PUSH above. The pop happens on the view's own _nav_stack.


Dispatched by replace(). Records the transition in session history. Does not modify the nav stack: replace() is a one-way transition, so the destination starts with its own empty view-local stack rather than inheriting the source's.

Payload:

Key Type Description
destination str Class name of the replacement view
params dict Extra transition parameters

State change: Appends a history entry to state["sessions"][session_id]["history"] with from_view, to_view_type, timestamp, and params.


State

SCOPED_UPDATE

Dispatched by dispatch_scoped(). Merges data into a namespaced slice of application state, keyed by scope type and identifiers.

Payload:

Key Type Description
scope str Scope type: "user", "guild", "user_guild", or "global"
identifiers dict IDs for key construction (e.g. {"user_id": 123})
data dict Fields to merge into the scoped slice

State change: Shallow-merges data into state["application"]["scoped"][scope_key], where scope_key is built by StateStore._build_scope_key(). Scoped data lives inside the application namespace so it shares the same persistence plumbing as named application slots.


COMPONENT_INTERACTION

Dispatched by StatefulButton and StatefulSelect after every callback invocation. Records the interaction for devtools history.

Payload:

Key Type Description
component_id str The component's custom_id
view_id str Parent view ID
user_id int \| None User who clicked
value Any The component's value at the end of the callback: a select's chosen options, a toggle's new state, True for a plain button. Any other keyword passed to the creator lands beside it under its own name.

State change: Appends to state["components"][component_id]["interactions"], capped at 50 entries. Sets last_interaction timestamp.


Dispatched by Modal.on_submit() when view_id is set. Records the submission for devtools history.

Payload:

Key Type Description
view_id str Parent view ID
user_id int \| None User who submitted
values dict Field values from the modal

State change: Appends to state["modals"][view_id]["submissions"], capped at 50 entries. Sets last_submission timestamp.


Persistence

PERSISTENT_VIEW_REGISTERED

Dispatched by PersistentView.send() after the message is sent. Stores the information needed to re-attach the view after a bot restart.

Payload:

Key Type Description
persistence_key str Dedupe key for the persistent view
class_name str View class name
message_id str Discord message ID
channel_id str Discord channel ID
guild_id str \| None Guild ID (DMs are None)
user_id str \| None Owner user ID

State change: Writes to state["persistent_views"][persistence_key].

PersistenceMiddleware flushes the registry namespace to disk immediately on this action.


PERSISTENT_VIEW_UNREGISTERED

Dispatched by PersistentView.exit(). Removes the persistent view from the registry.

Payload:

Key Type Description
persistence_key str The view's dedupe key
message_id str \| None The message the exiting view registered under. exit() fills it in; None removes the key whatever message it points at

State change: Deletes state["persistent_views"][persistence_key], unless message_id is set and the entry points at a different message. That case is a panel superseded under its key, whose successor owns the registration now.

PersistenceMiddleware flushes the registry namespace to disk immediately on this action.

REGISTRY_PRUNED

Dispatched when the persistence manager prunes rows from the cascadeui_persistent_views registry: a reattach pass removing the row of a panel whose channel or message no longer exists, the unreachable sweep, or a direct prune_registry() call. A built-in reducer drops each pruned key from state["persistent_views"], so the store's registry mirror matches the rows left on disk; re-registering a pruned key afterwards is a clean first registration rather than a duplicate-key cleanup against a stale entry.

The payload carries deleted (the row count), keys (the keys removed), reason (why they went), and source (the call that pruned).

During startup reattach, REGISTRY_PRUNED fires synchronously inside setup_middleware. Under the canonical setup order (cogs loaded before setup_middleware), a subscription wired in a cog's setup(bot) is already registered and does observe the action. Code subscribing only in on_ready or later misses it (dispatches once, no replay). For that case read store.persistence_manager.total_reattach_summary["removed"] after startup instead, which covers every reattach pass rather than the latest one; see the Persistence guide.

Payload:

Key Type Description
deleted int Number of rows removed
keys list[str] The persistence_keys actually pruned; a key passed in but absent on disk is not listed
reason str Why the rows went: "explicit" for a targeted prune, "clear_all" for a full wipe, "gone" when a reattach pass or prune_unreachable deleted rows whose channel or message returned a 404, "unreachable" when prune_unreachable deleted rows that stayed unreachable past its cutoff. prune_registry(reason=) passes any caller-supplied string through, so treat the value as open rather than a closed set
source str The call that pruned: "reattach" for a reattach pass, "prune_unreachable" for the unreachable sweep, "prune_registry" for a direct call

State change: Deletes state["persistent_views"][key] for every key in keys. The keys list also lets a subscriber clear exactly the affected external records without sweeping its whole domain against the registry.

APPLICATION_SLOTS_PRUNED

Dispatched by PersistenceManager.prune_application() and the daily TTL sweeper after deleting application slots. Dispatch-only: no reducer. Subscribe or use store.on("application_slots_pruned", ...) to observe prunes.

Payload:

Key Type Description
deleted int Number of application-slot rows removed
cutoff Optional[int] The expires_at threshold used (epoch seconds), or None for a single-slot prune
slots list[str] The slots removed from the running bot

State change: None from the action. The slots in slots leave state["application"] before it is dispatched.


Inspector

INSPECTOR_PURGED_STALE

Dispatched by DevToolsCog's /cascadeui purge subcommand and the Inspector's Purge Stale button to drop orphaned component-interaction and modal-submission rows.

Payload:

{
    "inspector_id": str | None,   # Live inspector whose own rows survive
}

State change: Drops entries from state["components"] and state["modals"] that are not owned by the given inspector. state["views"] is left alone. Passing None purges every row; omitting the key entirely is a no-op.


Undo / Redo

UNDO

Dispatched by undo() on views with enable_undo = True. Restores the previous application state snapshot.

Payload:

Key Type Description
view_id str View owning the undo stack
session_id str Session for shared_data restoration

State change:

  • Builds the inverse diff of the slots and keys the undo entry names, and pushes it onto the view's redo_stack alongside the current shared_data
  • Pops the top entry from the view's undo_stack and applies its diff, plus the shared_data it carries

The snapshot is a diff, not a copy of the whole application subtree: only what the undone action changed is restored, so other views' writes survive an undo here. Inside a dict slot the diff is per key, at every depth. Every user's scoped data shares the one scoped slot, and a guild's bucket in it is shared by its members, so restoring either whole would put back other users' state along with the undone change. A slot holding anything other than a dict is restored whole. Session data from update_session() is restored too.


REDO

Dispatched by redo() on views with enable_undo = True. Re-applies a previously undone snapshot.

Payload:

Key Type Description
view_id str View owning the redo stack
session_id str Session for shared_data restoration

State change:

  • Builds the inverse diff of the slots and keys the redo entry names, and pushes it onto the view's undo_stack alongside the current shared_data
  • Pops the top entry from the view's redo_stack and applies its diff, plus the shared_data it carries

Notification-only

BATCH_COMPLETE

Fired after a batch() context manager exits. Has no reducer and does not modify state. Subscribers listening for BATCH_COMPLETE can use it as a signal to rebuild UI once after a group of actions.


Middleware Behavior

Three middleware components interact with these actions:

  • PersistenceMiddleware fans writes across two namespaces (registry, application) with independent debounce windows per namespace. Scoped state rides under the application namespace; a scoped slot persists when its slot name is opted in via persistent_slots on the view class or via SlotPolicy(persistent=True) at setup time. The registry namespace carries a zero debounce, so PERSISTENT_VIEW_REGISTERED and PERSISTENT_VIEW_UNREGISTERED write immediately; the application namespace debounces. Skips bookkeeping actions (SESSION_CREATED, SESSION_UPDATED, VIEW_CREATED, VIEW_UPDATED, VIEW_DESTROYED, COMPONENT_INTERACTION, MODAL_SUBMITTED, NAVIGATION_PUSH, NAVIGATION_POP, NAVIGATION_REPLACE, BATCH_COMPLETE, INSPECTOR_PURGED_STALE, APPLICATION_SLOTS_PRUNED, REGISTRY_PRUNED) that don't carry application state changes. UNDO and REDO do, so an undone persistent slot is saved. Also skips any dispatch-only action (no registered reducer) where the state reference is unchanged. Slots default to in-memory; the middleware only writes slots opted in through _PERSISTENT_SLOTS.

  • UndoMiddleware captures a diff (per key at every depth, for a dict slot) of state["application"] across the reducer (holding the pre-state, computing the diff once the reducer returns) plus a copy of the session's shared_data, and pushes both onto the source view's undo_stack. Skips bookkeeping actions (VIEW_CREATED, VIEW_UPDATED, VIEW_DESTROYED, SESSION_CREATED, NAVIGATION_PUSH, NAVIGATION_POP, NAVIGATION_REPLACE, COMPONENT_INTERACTION, MODAL_SUBMITTED, UNDO, REDO, BATCH_COMPLETE, PERSISTENT_VIEW_REGISTERED, PERSISTENT_VIEW_UNREGISTERED, INSPECTOR_PURGED_STALE, APPLICATION_SLOTS_PRUNED, REGISTRY_PRUNED). Only snapshots when the dispatching view has enable_undo = True. SESSION_UPDATED is not skipped - update_session() changes are captured and restored by undo/redo.

  • LoggingMiddleware logs every dispatched action. The configured level (default INFO) is the action stream's emission level, not a threshold: pass level="DEBUG" to keep the routine action traffic out of INFO logs so it surfaces only when DEBUG is enabled. The middleware does not pin the logger threshold; visibility follows the cascadeui.actions logger and its handlers. A level name logging does not know raises ValueError; an int is used as given.

Action Filters and Undo

_notify_subscribers skips the action_filter gate for UNDO and REDO actions. All subscribers become candidates regardless of their subscribed_actions set. The selector comparison still runs, so only views whose selected state actually changed are notified. This enables cross-view undo reactivity without requiring views to subscribe to UNDO/REDO explicitly.