Views¶
Views are the primary UI containers in CascadeUI. They integrate discord.py's view system with a centralized state store, lifecycle management, and task tracking.
CascadeUI supports two component systems:
- V2 (recommended) --
StatefulLayoutViewwraps discord.py'sLayoutView. Content and controls live together in containers with accent colors. The view IS the message content. - V1 (classic) --
StatefulViewwraps discord.py'sView. Embeds sit on top, buttons float below. Content and controls are visually separated.
Both share the same state integration, navigation stack, instance limiting,
undo/redo, and all other framework features through a shared _StatefulMixin.
For the full policy attribute reference, see Core Concepts -- Policy Surface.
V2 Views (LayoutView)¶
The base class for V2 views. Unlike V1, there are no content or embed
parameters on send() -- the component tree IS the message:
from cascadeui import StatefulLayoutView, StatefulButton, card, divider
from discord.ui import ActionRow, TextDisplay
import discord
class MyView(StatefulLayoutView):
instance_limit = 1
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.build_ui()
def build_ui(self):
self.clear_items()
self.add_item(card(
"## My Dashboard",
TextDisplay("Welcome to the V2 interface."),
divider(),
ActionRow(
StatefulButton(
label="Click Me",
style=discord.ButtonStyle.primary,
callback=self.on_click,
),
),
color=discord.Color.blurple(),
))
self.add_exit_button()
async def on_click(self, interaction):
self.build_ui()
await self.refresh()
Sending¶
view = MyView(context=ctx)
await view.send() # No content/embed params -- the component tree is the content
Key Differences from V1¶
V2 (StatefulLayoutView) |
V1 (StatefulView) |
|
|---|---|---|
| Content | Component tree (Containers, TextDisplay) | Embeds + content string |
send() |
No content/embed params | Accepts content, embed, embeds |
| Interactive items | Must be wrapped in ActionRow |
Can be added directly |
| Exit behavior | Freezes components in place | Strips view, keeps embed |
| Accent colors | Per-container via card(color=...) |
One embed color |
| Components per message | Up to 40 | Up to 25 (5 rows × 5) |
ActionRow wrapping
Buttons and selects cannot be top-level children of a LayoutView. Always
wrap them in ActionRow before calling add_item(). The V2 builder
functions (card, action_section, toggle_section) handle this
automatically when buttons are part of a container.
V2 exit behavior
Calling message.edit(view=None) on a V2 message produces an empty message
(Discord error 50006) because the view IS the content. CascadeUI handles
this automatically -- exit() freezes all components with
_freeze_components() and edits with the frozen view, preserving visual
content.
DisplayLayoutView -- One-Shot V2 Sends¶
DisplayLayoutView is a parameterized StatefulLayoutView for cases where
the goal is to send a pre-built V2 container without authoring a full view
subclass. Pass the container as a container= kwarg and call send():
from cascadeui import DisplayLayoutView, card, key_value
body = card(
"## Session Stats",
key_value({"Games": 5, "Wins": 3}),
)
await DisplayLayoutView(context=ctx, container=body).send(ephemeral=True)
Interactive items inside the container still route through the normal
dispatch pipeline -- DisplayLayoutView trades per-instance state (no
build_ui override, no custom hooks) for the ability to instantiate
directly. Defaults differ from StatefulLayoutView to match the common
one-shot use case:
| Attribute | Default | Reason |
|---|---|---|
owner_only |
False |
Display cards are typically public |
state_scope |
None |
No per-scope state slice needed |
Use it for ephemeral confirmations, stats readouts, and error panels that don't warrant a dedicated class.
V1 Views (Classic)¶
The V1 base class wraps discord.py's View with embed-based content:
from cascadeui import StatefulView, StatefulButton
import discord
class MyView(StatefulView):
instance_limit = 1
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_item(StatefulButton(label="Click Me", callback=self.on_click))
self.add_exit_button()
def build_ui(self):
return {"embed": discord.Embed(title="My View", description="Hello!")}
async def on_click(self, interaction):
await self.respond(interaction, "Clicked!", ephemeral=True)
view = MyView(context=ctx)
await view.send(**view.build_ui())
V1 views use embeds for content with buttons below. build_ui() returns a
dict splatted into refresh() by the default on_state_changed().
For pre-built patterns (Menu, Form, Wizard, Tab, Paginated, Leaderboard, Roles) in V1 and V2 where applicable, see View Patterns.
Lifecycle¶
Every view follows the same lifecycle:
- Init -- view created, components added, subscribed to state store
- Send -- message sent, state registered (
VIEW_CREATED,SESSION_CREATED) - Interact -- user clicks buttons/selects, callbacks fire
- Exit/Timeout -- components disabled, state cleaned up (
VIEW_DESTROYED)
Timeout¶
Views timeout after 180 seconds by default. On timeout, all components are disabled, the message is edited, and the view unsubscribes from the store.
super().__init__(*args, timeout=300, **kwargs) # 5 minutes
super().__init__(*args, timeout=None, **kwargs) # Never timeout
Ephemeral timeout derivation
send(ephemeral=True) adjusts timeout based on auto_refresh_ephemeral:
- When
auto_refresh_ephemeral = True(or left at theNonedefault that auto-engages on long timeouts), a timeout below86400s(24 hours) is bumped up to86400s. The refresh handoff re-opens the view on a fresh interaction token before the webhook's 900-second cliff, so the state store lifetime must extend past it. - When
auto_refresh_ephemeral = False, a missing or larger timeout is clamped down to900sto match the interaction token's 15-minute cliff, so the view does not linger after its webhook token expires.
The derivation is centralized in _send_pipeline -- every view that
routes through send() (patterns included) inherits the policy.
Long-lived non-ephemeral views
After send(), both view classes re-fetch the message as a plain Message
via channel.fetch_message(). This replaces the InteractionMessage whose
edit() expires with the 15-minute interaction token. The plain
Message.edit() uses the channel REST endpoint with no token expiry, so
refresh() and on_timeout() work indefinitely. Ephemeral messages skip
the re-fetch (not fetchable via channel).
Reacting to State Changes¶
If a subclass defines build_ui(), the default on_state_changed() calls
it and then refresh() automatically. The minimal stateful view only needs
build_ui() and a subscribed_actions set.
V2 views mutate the component tree inside build_ui() and return None:
class MyView(StatefulLayoutView):
subscribed_actions = {"MY_ACTION"}
def build_ui(self):
self.clear_items()
# ... build the component tree from current state
V1 views return a dict splatted into refresh():
class MyView(StatefulView):
subscribed_actions = {"MY_ACTION"}
def build_ui(self):
return {"embed": discord.Embed(title="Counter", description=f"Value: {self.value}")}
Override on_state_changed() only when custom logic beyond rebuild + refresh
is needed:
class CustomView(StatefulLayoutView):
subscribed_actions = {"GAME_FINISHED"}
async def on_state_changed(self, state):
winner = state["application"]["last_winner"]
if winner == self.user_id:
await self._play_victory_sound()
self.build_ui()
await self.refresh()
subscribed_actions is opt-in
Views receive no notifications by default. Set subscribed_actions to the
action types the view needs to react to:
on_state_changed() (which calls build_ui()
+ refresh() by default), so subscribe only to actions the view reads.
Set subscribed_actions = None to receive all actions (not recommended).
send() and Rollback¶
send() handles message creation, state registration, session tracking, and
message reference capture in one call.
Return value: the sent discord.Message on success, or None when the
view was blocked. Two conditions produce None:
- Instance limit rejection --
instance_policy = "reject"and the user has hitinstance_limit. Theon_instance_limithook fires automatically. - Participant registration failure --
auto_register_participants = Trueand a user inallowed_usersalready occupies an instance.
In both cases, the library handles cleanup completely -- no message, no state tree entry, no registry slot.
view = ExpensiveView(context=ctx)
if await view.send() is None:
return # Block was handled by on_instance_limit or on_participant_limit
Overriding send(). Post-send work (starting timers, spawning children)
must be guarded behind result is not None:
async def send(self, *, ephemeral: bool = False):
result = await super().send(ephemeral=ephemeral)
if result is not None:
self._start_countdown()
return result
refresh(**kwargs)¶
Edits the view's message with view=self plus any extra kwargs. Does NOT
rebuild components -- call build_ui() first. Handles discord.NotFound
silently. V2 callers pass no args; V1 callers pass embed= or content=.
set_class_attribute(name, value)¶
Override a class attribute for this instance only. Useful when a policy needs to differ per-invocation without subclassing:
Navigation Stack¶
Push views onto a stack and pop them to go back:
class HubView(StatefulView):
async def go_settings(self, interaction):
await self.push(SettingsView, interaction,
rebuild=lambda v: {"embed": v.build_embed()})
class SettingsView(StatefulView):
async def go_back(self, interaction):
await self.pop(interaction,
rebuild=lambda v: {"embed": v.build_embed()})
The rebuild Callback¶
The Discord message edit fires on every push and pop. rebuild= is an
optional pre-edit hook for views that need post-construction setup:
- The interaction is auto-deferred
- The optional
rebuildcallback runs against the new view - The message is edited with the new view (plus any kwargs the callback returned)
V2 rebuild typically calls build_ui() to populate views that
construct empty. V1 rebuild returns a dict of edit kwargs
(e.g., {"embed": v.build_embed()}). Sync or async both work. Views
built by async classmethods like PaginatedLayoutView.from_data come
fully populated -- omit rebuild entirely.
How It Works¶
push()stops the current view, stacks it, and creates a new view instancepop()stops the current view and reconstructs the previous one- Constructor kwargs are preserved automatically for faithful reconstruction
- The new view inherits
session_id, keeping navigation within one session
Pushing Pre-Constructed Instances¶
push() and replace() accept either a view class (the default form
shown above) or a pre-constructed view instance. The instance form
pairs with async classmethod constructors -- PaginatedLayoutView.from_data
and from_cursor -- where the view is built before the navigation call.
class HubView(StatefulLayoutView):
async def go_inventory(self, interaction):
# from_data is async; build the view first, then push it.
child = await InventoryView.from_data(
items=ITEMS,
per_page=10,
formatter=format_inventory_page,
interaction=interaction,
)
# No rebuild -- from_data returns a fully-built paginator and
# push() edits the message on its own.
await self.push(child, interaction)
Passing extra kwargs alongside an instance raises TypeError -- the
instance is already built.
Coming from a paginator gist?
If you're migrating from @Soheab's CV2 paginator gist or classic paginator gist, see the migration map in the patterns guide for the full mapping of gist concepts to CascadeUI's grammar.
Auto Back Button¶
The auto-added back button survives pattern rebuilds. Paginated page
turns, tab switches, form re-layout, menu refresh, role panel rebuild,
and wizard step advance all call clear_items() and recompose the
component tree from scratch. The library re-adds the back button after
each recomposition via _restore_navigation_artifacts, so a view that
combines auto_back_button = True with a pattern's interactive
controls keeps both reachable across every state-driven rebuild.
Push vs. Replace¶
push() |
replace() |
|
|---|---|---|
| Stack | Adds entry, supports back | No stack, one-way |
| Session | Shared | Shared |
| V1/V2 mixing | Blocked | Allowed |
| Use case | Menu hierarchy | Replacing the view entirely |
Push/pop between V1 and V2
push() and pop() between V1 and V2 views raises TypeError. Discord's
IS_COMPONENTS_V2 flag is a one-way switch per message. Use replace() for
cross-version transitions. See
Known Limitations.
Instance Management¶
Most Discord bots track active views manually -- a dict mapping user IDs to view instances, checked at the top of every command:
# The manual approach (no library support)
active_games = {}
@bot.command()
async def game(ctx):
if ctx.author.id in active_games:
await ctx.send("You already have an active game.", ephemeral=True)
return
view = GameView()
active_games[ctx.author.id] = view
await ctx.send(view=view)
# ... and you need to remember to clean up on timeout, exit, error, etc.
CascadeUI replaces that entire pattern with three class attributes. The library
tracks instances in the state store, enforces limits on send(), handles
cleanup on exit/timeout/error, and counts participants (not just owners):
class SettingsView(StatefulLayoutView):
instance_limit = 1 # Max active instances (None = unlimited)
instance_scope = "user_guild" # Scope for counting instances
instance_policy = "replace" # What to do when the limit is reached
These attributes belong to Pillar 2 -- Instance Constraints.
Instance Scope¶
| Scope | Groups by | Use case |
|---|---|---|
"user" |
User ID | Per-user across all guilds |
"guild" |
Guild ID | Per-server, shared by all users |
"user_guild" (default) |
User + Guild ID | Per-user within each guild |
"global" |
Nothing | One instance across the entire bot |
Instance Policy¶
| Policy | Behavior |
|---|---|
"replace" (default) |
Exits the oldest view(s) to make room |
"reject" |
Blocks send(), fires on_instance_limit, returns None |
Replace Behavior: replace_policy¶
When replacing, the old message is either deleted or frozen:
class SettingsView(StatefulLayoutView):
replace_policy = "delete" # default -- old message is deleted
Set replace_policy = "disable" to keep the old view visible as a frozen
snapshot (useful for audit trails).
Attachment Protection: protect_attached¶
By default, views with active participants or attached children from other users cannot be silently replaced:
class GameView(StatefulLayoutView):
instance_limit = 1
instance_policy = "replace"
protect_attached = True # default
participant_limit = 2
auto_register_participants = True
When the owner tries to start a new game while their current game has a
participant or an attached child belonging to another user, the replacement
is blocked and on_instance_limit fires on the new view instead. Same-user
attachments do not trigger protection -- the owner can always replace their
own views.
Set protect_attached = False to allow silent replacement of views with
active attachments (e.g. spectator panels where replacement is expected).
Replacement Notification: on_replaced¶
When replacement proceeds (either protect_attached = False or the view
has no cross-user attachments), the old view's on_replaced() hook fires
before exit(). The view is fully intact at this point -- message,
participants, and channel access are all live.
Zero config -- silent replacement:
Static message -- notify the channel:
Dynamic override -- full control:
async def on_replaced(self):
if self._participants and self._message:
mentions = " ".join(f"<@{p}>" for p in self._participants)
await self._message.channel.send(
f"{mentions} game cancelled - the host started a new one."
)
Errors in on_replaced are logged but never block the new view's send().
Bare Exit Behavior: exit_policy¶
Controls what exit() does when called without an explicit delete_message:
Set exit_policy = "delete" for close buttons and on_timeout to delete the
message.
Both policies follow the three-tier precedence model: class attribute → method override → explicit argument.
Handling Rejection: on_instance_limit¶
Under reject policy, the library handles the block automatically:
Zero config -- sends an ephemeral with singular/plural phrasing:
Static message -- override the text:
Dynamic override -- full control:
async def on_instance_limit(self, error: InstanceLimitError) -> None:
if self.interaction is not None:
await self.interaction.followup.send(
f"<@{self.user_id}> is already in another game.",
ephemeral=True,
)
InstanceLimitError¶
The error object passed to on_instance_limit:
| Attribute | Type | Meaning |
|---|---|---|
view_type |
str |
Class name of the blocked view |
limit |
int |
The instance limit |
blocked_user_id |
int \| None |
User blocked (None for owner rejections) |
default_message |
str (property) |
Singular/plural-aware fallback text |
PersistentView Protection¶
Persistent views cannot be replaced by non-persistent views. If a regular view
tries to replace a persistent one, InstanceLimitError is raised instead.
Pre-Checking Availability (Command-Level Guard)¶
The declarative system handles enforcement automatically on send(), but
sometimes you want to check before constructing the view -- the same place
most bots do their manual if user_id in active_games check today:
@app_commands.command()
async def game(self, interaction: discord.Interaction):
if not GameView.check_instance_available(
user_id=interaction.user.id,
guild_id=interaction.guild.id,
):
await interaction.response.send_message(
"You already have an active game.", ephemeral=True
)
return
view = GameView(user_id=interaction.user.id, guild_id=interaction.guild.id)
await view.send(interaction)
This is useful when __init__ is expensive (e.g. fetching data from a database)
and you want to bail early. The check counts both owners and participants, so a
user who joined someone else's game counts against their limit. Returns True
when no instance_limit is set or when scope can't be determined (missing IDs).
Class Naming and Session Keys¶
CascadeUI identifies view classes by f"{cls.__module__}.{cls.__qualname__}".
Two classes sharing a short name in different modules are treated as distinct.
Override with session_class_key for stability across renames:
Interaction Ownership¶
By default, only the view owner can interact:
class MyView(StatefulLayoutView):
owner_only = True # Default
unauthorized_message = "You cannot interact with this." # Default
PersistentView and PersistentLayoutView default to owner_only = False.
Multi-User Access Control¶
For views shared between specific users:
class GameView(StatefulLayoutView):
unauthorized_message = "You're not part of this game."
def __init__(self, *args, opponent_id: int, **kwargs):
super().__init__(*args, **kwargs)
self.allowed_users = {self.user_id, opponent_id}
The setter coerces both int IDs and snowflake-shaped objects (Member,
User, Object).
Custom Access Control¶
Override interaction_check() for role-based or advanced logic:
class AdminView(StatefulLayoutView):
async def interaction_check(self, interaction):
if not await super().interaction_check(interaction):
return False
if not interaction.user.guild_permissions.administrator:
await interaction.response.send_message("Admins only.", ephemeral=True)
return False
return True
Participants and Multi-User Views¶
For multi-user views (games, polls, lobbies), register_participant adds
non-owner users to the session index:
joined = await view.register_participant(opponent.id, interaction=interaction)
if not joined:
return # Library already responded ephemerally
register_participant is async, returns bool, accepts int or snowflake.
Pass interaction so rejection hooks respond on the right interaction.
Participant Capacity: participant_limit¶
Caps total occupants (owner + participants):
class LobbyView(StatefulLayoutView):
participant_limit = 8
participant_limit_message = "This lobby is full."
The trio follows the standard policy grammar: participant_limit (cap),
participant_limit_message (static text), on_participant_limit() (dynamic
override).
Auto-Registration: auto_register_participants¶
For fixed-roster views where the player set is known at construction:
class BattleshipView(StatefulLayoutView):
participant_limit = 2
auto_register_participants = True
def __init__(self, *args, opponent_id: int, **kwargs):
super().__init__(*args, **kwargs)
self.allowed_users = {self.user_id, opponent_id}
Rollback is all-or-nothing and runs before the Discord send.
Combining allowed_users and participant_limit¶
allowed_users |
participant_limit |
Pattern |
|---|---|---|
| set | None |
Fixed roster, unlimited capacity |
| set | int | Fixed roster, capped (pedagogical redundancy) |
| empty | None |
Open interaction, unlimited |
| empty | int | Open join, capped (lobby pattern) |
Interaction Handling¶
Auto-Defer Safety Net¶
CascadeUI eliminates manual interaction.response.defer() calls for most
callbacks. Three mechanisms work together so the interaction is always
acknowledged, regardless of callback speed or response pattern:
-
Post-callback defer -- after every callback, CascadeUI checks whether the interaction was acknowledged. If not, it defers automatically. Callbacks that use the
dispatch() -> build_ui() -> refresh()pattern edit the message via the channel REST endpoint (not the interaction response), so the interaction goes unacknowledged by the callback itself. The post-callback defer catches this and acknowledges it instantly. -
Timed defer -- if a callback takes longer than
auto_defer_delay(default 2.5s) without responding, a background timer defers proactively. This covers slow operations like database queries or API calls. -
Interaction serialization -- when
serialize_interactions = True(default), rapid button clicks are processed sequentially viaasyncio.Lock. The timed defer runs outside the lock, so queued interactions are deferred before Discord's 3-second timeout.
class MyView(StatefulLayoutView):
auto_defer = True # Default -- enables all three mechanisms
auto_defer_delay = 2.5 # Seconds before the timed defer fires
serialize_interactions = True # Default -- sequential callback processing
This means most callbacks need no interaction handling at all:
async def _on_toggle(self, interaction):
self._enabled = not self._enabled
self.build_ui()
await self.refresh()
# No defer() needed -- CascadeUI handles it after the callback returns
Manual defer() is not needed in CascadeUI callbacks. For sending messages,
use self.respond() (see below).
For opening modals, use self.open_modal() -- it handles the case where
auto-defer already consumed the response slot:
Never call interaction.response.defer() manually
Manual defer() is actively harmful in two ways. First, under rapid
clicking with serialize_interactions = True, a queued interaction can
wait longer than auto_defer_delay for the callback lock; the timed
defer fires first, and the callback's own defer() then raises
InteractionResponded, killing the rest of the callback (build_ui()
never runs, refresh() never runs, the user sees a phantom click).
Second, pre-deferring flips interaction.response.is_done() to True,
which disqualifies the acting-view edit_message fast path in
refresh() and forces the refresh through the slower two-call channel
endpoint path. The auto-defer system already handles acknowledgement
for every callback. The only places manual defer is appropriate are
before interaction.followup.send() or outside CascadeUI's
_scheduled_task scope (e.g. a raw discord.ui.Modal.on_submit).
Patterns deliberately do not pre-defer
Library patterns (PaginatedView, TabView, WizardView, FormView,
and their V2 counterparts) do not call _safe_defer() inside their
component callbacks, even though the helper is available. Pre-deferring
inside a callback that rebuilds state and calls refresh() starves
the acting-view fast path. The post-callback defer in
_scheduled_task acks the interaction after the callback returns.
When writing your own patterns, follow the same shape: no defer, just
rebuild and refresh(). Use _safe_defer() only when you explicitly
want the slow path (e.g. long async work before refreshing).
See Opening Modals from Callbacks for details.
All three mechanisms check interaction.response.is_done() before acting, so
manual defer(), with_loading_state, with_confirmation, and with_cooldown
are all safe to combine with auto-defer.
Sending Ephemeral Feedback from Callbacks¶
Callbacks that need to send a message back to the user (turn enforcement,
validation errors, confirmations) should use self.respond() instead of
interaction.response.send_message():
async def my_callback(self, interaction):
if not allowed:
await self.respond(interaction, "Not your turn!", ephemeral=True)
return
respond() checks interaction.response.is_done() and automatically routes
to interaction.followup.send() when the response slot has already been
consumed by auto-defer. This matters under serialize_interactions where
queued interactions may wait long enough for the auto-defer timer to fire.
The method accepts all keyword arguments that send_message and
followup.send accept (embed=, view=, file=, ephemeral=, etc.),
and works for both ephemeral and public responses.
Opening Modals from Callbacks¶
Callbacks that open a modal should use self.open_modal() instead of
interaction.response.send_modal():
async def on_edit(self, interaction):
modal = Modal(title="Edit Name", inputs=[name_input], callback=self.handle_edit)
await self.open_modal(interaction, modal)
send_modal() must be the first response to an interaction -- it cannot
follow a defer(). Under serialize_interactions, a queued interaction
may be auto-deferred before the callback runs, making raw send_modal()
raise InteractionResponded. open_modal() checks is_done() and sends
an ephemeral "please try again" fallback instead of crashing.
The method returns True if the modal opened, False if the fallback
fired. Pass fallback_message= to customize the fallback text.
Ephemeral Views¶
Auto-Refresh for Long-Lived Ephemerals¶
Ephemeral views can survive Discord's 15-minute editability wall by engaging
the auto_refresh_ephemeral handoff. The default (None) derives the behavior
from timeout: short-lived ephemerals (timeout <= 900) decline the handoff
and expire naturally; longer timeouts (or None) engage it. Shortly before the
wall, the library replaces the view's children with a "Continue Session"
button. Clicking it uses a fresh interaction token to send a replacement
ephemeral with another full 15-minute window.
Pin the behavior explicitly on short display ephemerals if you want to skip the derivation:
Customization knobs:
| Attribute | Default | Purpose |
|---|---|---|
refresh_warning_seconds |
90 |
How early to swap |
refresh_button_label |
"Continue Session" |
Button text |
refresh_button_emoji |
"🔄" |
Button emoji |
refresh_button_style |
ButtonStyle.primary |
Button style |
reopen_failure_message |
"Could not refresh..." |
Sent when the refresh fails |
Override _build_refresh_button() for deeper customization (custom
custom_id, row placement, additional callbacks).
Handling Refresh Failures: on_reopen_failure¶
When the refresh button fails to construct a replacement view, the
on_reopen_failure hook fires. Two failure modes:
- Factory raised (
erroris anException): the default implementation sendsreopen_failure_messageas an ephemeral. - Factory returned
None(errorisNone): the session has ended. The default sends "This session has ended." and callsexit().
class MyView(StatefulLayoutView):
reopen_failure_message = "Session expired. Run /start again."
# Or override the hook for full control:
async def on_reopen_failure(self, interaction, error=None):
if error:
await interaction.response.send_message(
f"Refresh failed: {error}", ephemeral=True
)
else:
await interaction.response.send_message(
"Done! Thanks for playing.", ephemeral=True
)
await self.exit()
See Known Limitations -- Ephemeral Editability for the full rationale and ghost-panel behavior.
State Scoping¶
Isolate state per user or per guild:
class SettingsView(StatefulLayoutView):
state_scope = "user"
async def click(self, interaction):
current = self.scoped_state.get("clicks", 0)
await self.dispatch_scoped({"clicks": current + 1})
Scope Values¶
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 namespace |
None (default) |
N/A | Shared state -- dispatch_scoped unavailable |
state_scope vs instance_scope
Both accept the same string values but govern different subsystems.
state_scope controls Redux scoped state (where data is stored).
instance_scope controls instance limit indexing (how instances are counted).
Reading Scoped State¶
# 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()
# Named accessors accept overrides for reading other users' data
other_user = self.user_scoped_state(user_id=other_id)
Writing Scoped State¶
Scoped state persists through restarts when a persistence backend is configured.
Cross-View Reactivity¶
dispatch_scoped() fires SCOPED_UPDATE, which other views don't subscribe
to by default. For live cross-view updates, use dispatch() with a named
action and a custom reducer:
| Method | Other views react? | Creates undo snapshots? |
|---|---|---|
dispatch_scoped() |
No | Yes |
dispatch("NAMED_ACTION") |
Yes | Yes |
When multiple dispatches converge on the same subscriber concurrently (e.g. two players acting simultaneously), notifications are coalesced automatically. See Concurrent Updates for details.
Undo/Redo¶
Enable undo/redo on any 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()
Only state["application"] is snapshotted. Internal lifecycle actions are
excluded from undo tracking.
Child Attachment¶
Parent views can register children for automatic cleanup. The parent=
kwarg is the recommended approach -- send() calls attach_child
automatically on success:
class GameView(StatefulLayoutView):
async def _show_panel(self, interaction):
panel = PanelView(context=interaction, parent=self)
await panel.send(ephemeral=True)
When the parent exits or times out, attached children are exited with
delete_message=True. attach_child() still works standalone for manual
use cases where the timing or conditional logic differs.
Three invariants are enforced on attachment: self-attachment raises
ValueError, circular chains raise ValueError (ancestor walk), and
re-parenting detaches from the old parent cleanly.
Dispatch before cleanup
When broadcasting a terminal action AND cleaning up children, dispatch first. Children need the final state update before exit:
Message Deletion Cleanup¶
When a view's Discord message is deleted externally (admin delete, bulk purge,
channel delete), the library automatically cleans up the view's state, tasks,
and store registration. The on_message_delete() hook fires by default and
calls exit(delete_message=False).
Override the hook for custom behavior:
class MyView(StatefulLayoutView):
async def on_message_delete(self):
print(f"View {self.id} message was deleted")
await self.exit(delete_message=False)
The cleanup listener is installed automatically on first send() or when
PersistenceMiddleware(bot=self) initializes. No manual setup is required.
Exit Button¶
self.add_exit_button() # "Exit" button with ❌ emoji
# Customize:
self.add_exit_button(label="Close", emoji=None, delete_message=True)
# PersistentView requires custom_id:
self.add_exit_button(custom_id="my_view:exit")
Error Handling¶
All views include a built-in on_error handler that shows a red ephemeral
embed when a callback raises an exception. The embed description uses the
error_message class attribute:
class MyView(StatefulLayoutView):
error_message = "Something broke. Please try again or contact support."
Override on_error for fully custom error handling (different embed layout,
DM the bot owner, conditional logging):
async def on_error(self, interaction, error, item):
logger.critical(f"View error: {error}")
await self.respond(interaction, "Bug reported!", ephemeral=True)
Persistent Views¶
Views that survive bot restarts. All interactive components must have an
explicit custom_id:
from cascadeui import PersistentLayoutView, StatefulButton, card
from discord.ui import ActionRow
class RolePanel(PersistentLayoutView):
instance_limit = 1
instance_scope = "guild"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_item(card(
"## Role Selector",
ActionRow(StatefulButton(
label="Get Role",
custom_id="roles:get",
callback=self.toggle_role,
)),
color=discord.Color.blurple(),
))
async def toggle_role(self, interaction):
...
See Persistence for backend setup, PersistenceMiddleware,
and the full persistent view lifecycle.
Defensive Input Handling¶
CascadeUI catches malformed input at two boundaries:
Snowflake Coercion (Instance-Level)¶
user_id, guild_id, allowed_users, and register_participant accept
either int or any object with .id: int:
view.allowed_users = {member, 12345, discord.Object(id=99999)}
await view.register_participant(opponent) # discord.Member works
Class-Attribute Validation (Definition Time)¶
String enums, positive numbers, and booleans are validated when a subclass is
defined via __init_subclass__:
The traceback names the class, attribute, bad value, and valid options.