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_idfrom its session's view list - Deletes the session entirely once its member list is empty
- With
session_idand no row for the view (a send rolled back before itsVIEW_CREATEDlanded), 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:
Read the result with the shared_data property:
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.
Navigation¶
NAVIGATION_PUSH¶
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.
NAVIGATION_POP¶
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.
NAVIGATION_REPLACE¶
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.
MODAL_SUBMITTED¶
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:
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_stackalongside the currentshared_data - Pops the top entry from the view's
undo_stackand applies its diff, plus theshared_datait 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_stackalongside the currentshared_data - Pops the top entry from the view's
redo_stackand applies its diff, plus theshared_datait 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:
-
PersistenceMiddlewarefans 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 viapersistent_slotson the view class or viaSlotPolicy(persistent=True)at setup time. The registry namespace carries a zero debounce, soPERSISTENT_VIEW_REGISTEREDandPERSISTENT_VIEW_UNREGISTEREDwrite 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.UNDOandREDOdo, 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. -
UndoMiddlewarecaptures a diff (per key at every depth, for a dict slot) ofstate["application"]across the reducer (holding the pre-state, computing the diff once the reducer returns) plus a copy of the session'sshared_data, and pushes both onto the source view'sundo_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 hasenable_undo = True.SESSION_UPDATEDis not skipped -update_session()changes are captured and restored by undo/redo. -
LoggingMiddlewarelogs every dispatched action. The configuredlevel(defaultINFO) is the action stream's emission level, not a threshold: passlevel="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 thecascadeui.actionslogger and its handlers. A level name logging does not know raisesValueError; 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.