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.
A posted-and-forgotten display card (an announcement or receipt with no
interactive components) needs no explicit teardown: when there is nothing to
disable, the timeout and exit() skip the cosmetic freeze edit, so the view
cleans up its state on timeout without a wasted request. With nothing to
click, the timeout counts from the card's last edit, so a card your code keeps
current stays up; one that should outlive its timeout between edits sets
timeout=None. To tear it down immediately while leaving the message on
screen, call exit(delete_message=False); it is the teardown seam, unlike discord.py's
stop(), which cancels the timeout but leaves the view in the active-view
registry.
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 --
on_pre_sendruns first as a veto gate (returnFalseto abort with no side effects), thenon_load, placement validation, instance enforcement, state registration (VIEW_CREATED,SESSION_CREATED), and the Discord send - 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. A
view pushed onto a persistent panel's message returns to the panel instead,
unless it defines its own on_timeout() (see
Retiring a registration).
super().__init__(*args, timeout=300, **kwargs) # 5 minutes
super().__init__(*args, timeout=None, **kwargs) # Never timeout
Overriding on_timeout
The default implementation is the teardown: it exits attached children,
cancels tasks, unsubscribes from the store, drops the registry entries,
and then ships the freeze edit. An override that neither delegates to
super().on_timeout() nor calls exit() leaves the view subscribed and
registered with its instance-limit slot still claimed. Either one tears
it down; composing a closing card and then calling
exit(delete_message=False) is the common shape. Send the card through
refresh() or a dispatch that renders it: an edit made with
message.edit() bypasses the library, so a render still loading when the
view closes can land over it.
A closing render works from either direction. Dispatching drives the
normal subscriber path, and build_ui() followed by refresh() edits the
message without touching state:
async def on_timeout(self):
self.show_buttons = False
await self.dispatch("ROUND_ENDED", {"view_id": self.id})
await super().on_timeout()
Render first, then tear down. Both super().on_timeout() and exit()
unsubscribe, so a dispatch issued after either no longer reaches this
view (other subscribers still receive it).
Ephemeral timeout derivation
send(ephemeral=True) derives the refresh-handoff behavior from the
declared timeout and keeps the derived answer internally, so
auto_refresh_ephemeral still reads whatever the class or the caller set.
discord.py gives an ephemeral view declared timeout=None a 900-second
timeout. With the handoff engaged the library puts None back, so the
panel lives until exit() or a restart and its Continue button works
whenever the user returns.
- Left at the
Nonedefault, the refresh handoff engages whentimeout is None or timeout > 900: the view intends to outlive the 15-minute webhook window, so the handoff re-opens it on a fresh interaction token before the 900-second cliff. An in-window ephemeral (timeout <= 900) declines the handoff. - An explicit
auto_refresh_ephemeral = TrueorFalseoverrides the derivation entirely.
Dismissing an ephemeral message sends the bot no event, so a panel declared
timeout=None with the handoff engaged stays registered after the user
dismisses it, until exit() or a restart. On a class with an
instance_limit, under instance_policy = "reject" or once participants
have registered, that refuses the user's next open until then. Give such a
panel a long finite timeout, such as 86400, instead.
The derivation lives in send(), so every view sent through it
(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
send put no live view on a new message. Four conditions produce None:
on_pre_sendveto -- the override returnedFalse. No message ships, no state registers. The response slot stays open for the override to explain the rejection.- 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. - Closed or stopped during the send -- an
exit()closed the view while it was being sent, orstop()stopped it. Before the view had loaded, nothing is posted; after that, the message is posted and then frozen or deleted as the close asked, and a stopped view is closed asexit()closes it.
In every case, the library handles cleanup completely: no state tree entry, no registry slot, and no live message. A view already live on a message and sent again stays live there when the new send is blocked.
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=.
A transport failure (a request that never reached Discord) is swallowed
rather than raised, and refresh_degraded reports it. See
Transport Failures Degrade Quietly.
Only content, embed, embeds, attachments, and allowed_mentions are
accepted. refresh() picks its edit endpoint at runtime, and a kwarg only one
endpoint supports (suppress, delete_after) raises TypeError rather than
working some of the time. Edit view.message directly for a one-off that
needs a non-portable field.
Mention rules: allowed_mentions¶
A view that names users in its rendered body re-pings them on every re-render unless it says otherwise: a leaderboard rebuilt on each score change, a roster that redraws on every join. Each edit is a fresh message payload, and Discord notifies on every one.
Set the class attribute once and it applies to the initial send and to every refresh, navigation edit, and teardown freeze the view makes:
None (the default) defers to the bot's client-level AllowedMentions, which
holds on all three edit endpoints. Pass allowed_mentions= to send() or
refresh() to override the attribute for one message, which is what a winner
announcement that genuinely should notify wants. LeaderboardLayoutView
suppresses by default, since a ranking that re-pings its top ten on every
update is rarely what the caller intended.
set_class_attribute(name, value)¶
Override a class attribute for this instance only. Useful when a policy needs to differ per-invocation without subclassing:
The override takes effect from the view's next render, so a pattern built for one user's language can relabel its buttons before it is sent:
pages = await PaginatedLayoutView.from_data(items, per_page=5, formatter=fmt, interaction=interaction)
pages.set_class_attribute("next_button_label", "Suivant")
await pages.send()
Attributes the library reads with no view instance in hand, such as
session_continuity, are refused with a ValueError: set those on the class
body.
An override stays with the panel through pop() and the Continue button, which
rebuild the view from its constructor arguments. A restart does not carry it,
since a persistent panel is restored from those arguments alone. Take the value
as a constructor argument and set it in __init__, and the restore sets it
again:
class Lobby(PersistentLayoutView):
def __init__(self, *args, players: int = 4, **kwargs):
super().__init__(*args, **kwargs)
self.set_class_attribute("participant_limit", players)
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 destination tree is built before the interaction is acked
- The optional
rebuildcallback runs against the new view - The message is edited with the new view (plus any kwargs the callback returned)
Navigation edits and acks in one round-trip through
interaction.response.edit_message when the response slot is still open,
falling back to defer plus edit_original_response when the slot is
already consumed (an auto-defer timer fired, or the caller pre-deferred).
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.
Omitting rebuild does not skip the rebuild. The destination's own
nav_rebuild applies instead, which is how
the V1 patterns render themselves without every call site passing one.
rebuild= handles sync post-construction tree or embed setup. Views
whose content comes from a database or other async source define
on_load(); the library calls it
automatically before every push/pop edit, so those views fetch their own
source on navigation.
The hook runs after that on_load(), so it never reloads the
destination: calling its reload() or load() from rebuild= raises
RuntimeError. Set anything the load reads before navigating, as a
constructor keyword or on a view built and passed to push().
How It Works¶
push()stacks the current view and edits the message to a new view instancepop()edits the message to the previous view, reconstructed from the stack- Constructor kwargs are captured automatically and replayed on that reconstruction
- The new view inherits
session_id, keeping navigation within one session - Only the navigation edits the message while it runs. The current view stops only once the edit lands: if it fails, the current view stays live on the message, its background tasks still running, and renders anything it was asked to while the edit was in flight.
push()still returns the new view, and navigating from or sending it raisesRuntimeError. The new view renders nothing until its edit lands - Once the edit lands, the message belongs to the new view. The old view's
messagereadsNone, its tasks are cancelled,push(),pop(), orreplace()on it raisesRuntimeError, andexit()on it does nothing: navigate from, or close, the view the call returned. Code that keeps a reference to the panel reaches the screen now on it throughcurrent_view. A task the old view owned that made the call carries on as the new view's - A click that reaches a view after it has navigated away or closed (a double-clicked Back, say) is acknowledged and dropped, and anything else that acts on either view mid-push, like
exit()or a parent's cleanup, waits for the push to finish - The new view's
on_load()and the rebuild hooks run inside the navigation, so they cannot navigate or close either view:push(),pop(),replace(), andexit()raiseRuntimeErrorthere. Decide where to go before navigating, or act on the view the navigation returns - A view that has closed cannot be navigated from:
push()orpop()afterexit(), a timeout, orstop(), or onceexit()has begun, raisesRuntimeError - A click can reach a view as soon as its message is posted, while
send()is still finishing: a push or pop then waits for the send. Called from inside the send itself (the view'son_load(),seed_initial_state(), or a hook the send runs),push()andpop()raiseRuntimeError, since the view has no message to hand over yet - A
push(),pop(), orreplace()from code while an ephemeral panel's Continue is sending its replacement waits for the Continue and, once the replacement is live, raisesRuntimeErroras on any view that handed its panel on: navigate fromcurrent_view - A push or pop with no interaction to answer (one from a background task, on a view sent to a channel) loads the new view and edits the message through the channel, like any other
What a Pop Restores, and What It Does Not¶
pop() does not hand back the object you left. It builds a new one from
the keyword arguments the original was constructed with, then re-runs
on_load(). So the data is fresh and the constructor arguments are intact --
but anything the view selected since construction was never an argument, and
a new object starts at its defaults.
That distinction is quiet. The rebuilt view renders its defaults without complaint, and the write that follows lands somewhere the user never chose:
class PolicyView(StatefulView):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._severity = "high" # a default, not a kwarg
# ...the admin picks "low", drills into a sub-screen, presses Back...
# ...and the rebuilt view is editing "high" again.
Three tools carry state across that boundary. Pick by what the state is:
| The state | The tool |
|---|---|
| Something this view selected (a page, a tab, a tier) | get_nav_state() / restore_nav_state() |
| Something the parent and child both read | shared_data via update_session() |
| Post-construction setup that is not data loading | rebuild= on the push() / pop() call |
Name what should survive and the library replays it:
def get_nav_state(self):
return {"severity": self._severity}
def restore_nav_state(self, state):
self._severity = state.get("severity", self._severity)
restore_nav_state() runs after __init__ and before on_load(), so a
preload reads the restored selection rather than the default: one fetch,
against the right row. The mapping rides the navigation stack and is never
serialized, so it may hold live objects.
The built-in patterns already do this for their own cursors: a paginated view comes back on the page the user left, tabs on the tab they opened, a wizard on its step, and a form with the values they typed.
The V2 composites stop short of that, deliberately. A PaginatedRegion's page
and a Collapsible's expanded state live on the host that built them, and only
the host knows whether returning to a drill-down should resume where the user
left off or start clean. Name them and they ride along like anything else:
def get_nav_state(self):
return {"page": self.pager.page}
def restore_nav_state(self, state):
self.pager.set_page(state.get("page", 0))
A host that names nothing gets a region back on page one, the same state a fresh open shows.
shared_data sits on the session rather than the view, so it already outlives
both ends of a push. Reach for it when a parent and child genuinely share data,
and for get_nav_state() when the state belongs to one view.
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 the classmethod constructors (PaginatedLayoutView.from_data,
which is awaited, and from_cursor, which is not) where the view is
built before the navigation call. An instance goes on one message once:
push() and replace() raise RuntimeError for one that has been sent,
pushed, or closed, so build a new one for each navigation rather than
caching it.
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.
Navigating database-backed views¶
Views that load their content from a database define on_load() instead
of passing rebuild= on every push and pop. The library calls on_load()
automatically before the initial send and before every push/pop edit, so
navigating to a child or back to a parent re-fetches its source on render:
class TaskListView(StatefulLayoutView):
subscribed_actions = set()
def __init__(self, *args, db, **kwargs):
# db is a non-reserved kwarg, so push/pop reconstruction
# preserves it faithfully.
self.db = db
super().__init__(*args, **kwargs)
self.rows = []
self.build_ui()
async def on_load(self):
# Runs before the initial send and before every push/pop edit.
self.rows = await self.db.list_tasks(self.user_id)
self.build_ui()
def build_ui(self):
self.clear_items()
lines = "\n".join(f"- {r['title']}" for r in self.rows) or "_No tasks yet._"
self.add_item(card("## Tasks", lines))
self.add_item(self.make_nav_row())
async def on_refresh(self, interaction):
# In-view Refresh button: re-fetch and re-render in place.
await self.reload()
A parent pushes to it directly; on_load() runs on the destination view
before the edit ships:
make_nav_row() builds the Back plus Exit footer (V2 only). Popping back
to the parent re-runs its on_load() and re-fetches its rows before the
edit ships, so the Back button drives the reload on its own. reload() is
the out-of-band counterpart for an in-view Refresh button: it runs
on_load() then refresh(). load() runs the same on_load() without
the edit, for a view whose loaded data another view reads.
Every on_load() run on one view is serialized, whichever of these started
it, so two runs never interleave on the view's half-built state and the last
to run renders the freshest data. The library's own fetches outside
on_load() take the same turn: a leaderboard's rebuild on a state change,
a tab or wizard step render, and a from_cursor view's page loads and
refresh_pages(). An on_state_changed() override that fetches for itself
does not, so put the fetch in on_load(). A state change that arrives
while a run is in progress renders once it finishes, rather than rendering
the view part-way through a load.
Keep on_load() to this view's own data: an on_load() that awaits another
view's reload() or load(), while that view's on_load() awaits this
one, raises RuntimeError instead of hanging both. Reload the other view
after this reload returns. The check follows direct awaits; the same cycle
routed through asyncio.gather() or a task the on_load() created cannot
be seen, and logs a warning once the wait passes 30 seconds.
The same shape stalls an on_state_changed() override: a render that awaits
a separate task reloading its own view (await asyncio.gather(self.reload()))
waits on a reload that is waiting for the render. Nothing raises there; the
warning naming the view is logged after 30 seconds. Await self.reload()
directly, or reload once on_state_changed() has returned.
Database handles go in a non-reserved kwarg
Store the database handle under a kwarg the framework does not manage
(db= above). The
reserved constructor parameters
(context, interaction, state_store, session_id, user_id,
guild_id, parent) are read for access control, session
derivation, and instance scoping. A repo smuggled through user_id is
read as the view's owner, and every click is gated on it.
See examples/v2_db_navigation.py
for a runnable cog.
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.
V1 views: name your own rebuild
A V1 view's content is its embed, and the back button is library code
with no rebuild= to pass. So a plain StatefulView with
auto_back_button = True pops back with its own buttons above the
child's embed until it says what its edit needs:
class Hub(StatefulView):
auto_back_button = True
nav_rebuild = staticmethod(lambda v: {"embed": v.build_embed()})
The built-in V1 patterns (PaginatedView, TabView, WizardView,
FormView, MenuView) already set one. V2 views need nothing: a
StatefulLayoutView is its component tree, so swapping view= is
the whole render.
Knowing Whether Back Belongs¶
nav_depth reports how many views sit beneath this one on the navigation
stack. It is 0 on a view that was sent rather than pushed, so it answers
"is there anywhere to go back to?" directly:
def build_ui(self):
self.clear_items()
self.add_item(card("Settings"))
self.add_item(self.make_nav_row(back=bool(self.nav_depth)))
It sits beside undo_depth and redo_depth, which report the same thing
for the undo and redo timelines.
A Back button on an empty stack renders disabled, and a click on any disabled
component (one sent from an older render) is acknowledged without touching the
message. make_nav_row() defaults to back=True, so a view that is sent
directly would otherwise show a Back button that does nothing when pressed.
The disabled state resolves at the render seams rather than inside
make_nav_row(), so a button inspected right after it is built still reads
disabled=False. A pushed view is constructed before its stack is assigned,
and reading the stack at build time would disable a working button on any view
that composes its tree in __init__.
Push vs. Replace¶
push() |
replace() |
|
|---|---|---|
| Stack | Adds entry, supports back | No stack, one-way |
| Message | Edited in place | The new view sends its own |
| Current view | Hands its message over | Closes as its exit() would |
| 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" to have close buttons delete the message instead.
The exit controls the library builds (make_exit_button, add_exit_button,
make_nav_row) all consult the policy; pass delete_message=True or False
to one of them to override it for that button alone. The same policy closes
the message a view leaves when it is sent again: once the new message is
posted, the old one is frozen as it looked when the send began, or deleted,
and stops answering clicks.
Sending again needs a view that is still open. send() raises
RuntimeError instead of posting when the view has closed (exit(), a
timeout, stop(), or a send that failed or was refused), is already being
sent, is running its on_timeout(), or navigated away with push() or
pop(). Build a new instance for those cases.
Push/pop stacks should agree on the policy
exit_policy is declared per class, but push() and pop() edit one
message in place. A stack whose screens disagree tears the same message
down differently depending on how deep the user went. Each class is
individually valid, so nothing at definition time can see the
disagreement. The library logs a warning on the first navigation step
that crosses one. Declare the policy on a shared base class when every
screen should agree; a destination that deliberately differs (a
confirmation that deletes while the hub freezes) is a legitimate shape
and still works.
on_timeout is not governed by this policy. A timed-out view always freezes,
because an expiry is not a close gesture, and its message may still hold
content the user is looking at. Override on_timeout to delete on expiry.
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) |
scope |
str \| None |
The instance_scope the limit counted under (None for a participant) |
default_message |
str (property) |
Fallback text worded for the scope: the user, the server, or everyone |
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(interaction=interaction)
await view.send()
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).
A screen reached by push() counts under the view its chain started from, with
that view's instance_scope and instance_limit, so opening the root again sees every screen in the
chain, and a pre-check on the root class covers them all. A pushed class checked
on its own counts only the instances sent directly, and returns True for a
user already inside a chain. Pass the root's key as session_origin to count
the way the library does for a pushed view, for example before a joiner is
added to a game the hub pushed:
HUB_KEY = f"{HubView.__module__}.{HubView.__qualname__}"
if not GameView.check_instance_available(
user_id=joiner.id,
guild_id=interaction.guild.id,
session_origin=HUB_KEY,
):
await interaction.response.send_message("You are already in a game.", ephemeral=True)
return
The check then counts under the hub's key and the hub's instance_scope,
against GameView.instance_limit: the same count register_participant()
makes on the pushed game. A root that sets session_class_key is keyed by that
value instead (see Class Naming and Session Keys).
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.
The key feeds session IDs, the instance index, session origin tracking, and,
for persistent views, the view_class column each registry row stores across
restarts.
A session_class_key class attribute overrides the derived name:
The pin does not inherit; each class opts in for itself. Its load-bearing use is persistent panels, whose stored rows resolve only against the name they recorded, so the pin is what keeps a moved or renamed class reattaching. See Class identity for the failure shape and the pin's requirements.
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) via coerce_snowflake_id_set().
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 instance 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
(coerced via coerce_snowflake_id()). 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, and it must stay under3.0) 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
ack_first -- acknowledge before the callback (advanced)
ack_first = False by default. When True, the view acknowledges the
interaction immediately -- before the access checks and the callback run,
earlier than even the timed defer. Reach for it only when a callback does
synchronous work that can stall the event loop past the 2.5s timer (heavy
CPU work between awaits, for example). It trades the one-request acting-view
refresh (edit-as-ack) for a guaranteed early ack, so a normal view is faster
without it. Do not combine ack_first = True with open_modal() in the
same callback: a modal must be the first response, and the early ack has
already consumed that slot.
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.
with_loading_state, with_confirmation, and with_cooldown are safe to
combine with auto-defer. Each one either catches discord.InteractionResponded
itself or routes its reply through respond(), which falls back to a followup
when the slot is already spent. A manual interaction.response.defer() has
neither fallback: the timer is armed outside the interaction lock, so it can
take the slot between an is_done() check and the call that follows it, and
the resulting InteractionResponded propagates. See the warning above.
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.
A modal stays open after the view that opened it closes. Submitted then, its
callback still runs, and the user is told "This session has ended." unless the
callback replied. Reply with self.respond(), or defer first and then send a
followup, so the reply is seen; a raw interaction.followup.send() sent without
deferring first is followed by the notice too. Override on_session_ended() to
answer these submissions your own way, or set session_ended_message = None to
send nothing. A callback that rebuilds the
view and calls refresh() updates the closed message with its controls as the
close left them, disabled or removed, so the panel still reads as closed.
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. Once the button is up, state
changes no longer render, and a refresh() from your own code (in the callback
of a modal submitted late, for example) puts the button back in place of
whatever that code rebuilt.
Pin the behavior explicitly on short display ephemerals if you want to skip the derivation:
The deadline is stamped at send() and measured from that original send,
because the 15-minute window belongs to the original interaction. Navigation
never restarts the clock: push() and pop() destinations arm against the
chain's deadline, each under its own auto_refresh_ephemeral. An explicit
False keeps the handoff off on every path, an explicit True engages it
even when the original send declined, and a destination left at the None
default inherits the effective policy of the view it navigated from: the
nearest hop's explicit setting when one exists, else the answer the original
send derived. Its own timeout is not consulted, and its
auto_refresh_ephemeral is not modified -- the inherited answer rides the
chain internally. The one thing a destination cannot change is the window
itself: however the handoff engages, the button arms before the original
send's 15 minutes run out.
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 (a custom
custom_id, row placement). Start from super().build_refresh_button() so the
button still runs the handoff. An override of the older name,
_build_refresh_button(), still works and warns that it is deprecated.
Building the Replacement: build_reopen_view¶
Continue sends the view build_reopen_view(interaction) returns. The default
constructs the view's class again with the keyword arguments it was built
with, as pop() does, and the replacement takes over the panel: its
navigation stack, the selection get_nav_state() reports, its session, undo
history, participants, and attached views carry over. Override it when that
reconstruction is not the right view: a constructor with side effects, a
replacement that needs a live reference the old view holds, or a different
view to continue with. It runs on the old view, and may be async def:
class GameView(StatefulLayoutView):
timeout = None
async def build_reopen_view(self, interaction):
game = await load_game(self.game_id)
if game.finished:
return None # ends the session instead of reopening
return GameView(interaction=interaction, game_id=self.game_id)
Return None to end the session instead: on_reopen_failure runs with
error=None.
Handling Refresh Failures: on_reopen_failure¶
When the refresh button cannot send a replacement view, the
on_reopen_failure hook fires. Two failure modes:
build_reopen_viewraised, or returned a result that cannot be sent (erroris anException): the class instead of an instance, or a view already sent or closed, counts as a raise. The default implementation sendsreopen_failure_messageas an ephemeral.build_reopen_viewreturnedNone(errorisNone): the session has ended. The default runson_session_ended(), which sendssession_ended_message("This session has ended." by default;Nonesends nothing), 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 is in-memory by default. It survives a restart when its slot
is opted in (persistent_slots = ("scoped",)) and a persistence backend is
configured; see Scoped State.
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, setup_middleware
await setup_middleware(UndoMiddleware())
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()
Undo covers state["application"] and the session's shared_data.
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 still open are
exited with delete_message=True; one that already closed, by its own
timeout or exit(), keeps what that close left. attach_child() still
works standalone for manual use cases where the timing or conditional logic
differs.
exit_children() runs the same cascade while the parent stays open, for
companions that end before it does. Pass delete_message=False to freeze
them instead, or None to follow each child's exit_policy. A view attached
while it runs, such as the next round's panel, is left for the next call or
the parent's own exit.
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, the library cleans up the
view's state, tasks, and store registration (and, for a persistent view, its
registry row). The on_message_delete() hook fires and, by default, calls
exit(delete_message=False). Three signals reach it:
| The message went because | How the library learns | Intents needed |
|---|---|---|
| Someone deleted it, or purged it in bulk | The gateway's message-delete events | guild_messages (or dm_messages in a DM) |
| Its channel or thread was deleted | The channel and thread delete events; Discord sends no message deletions for these | guilds |
| Either of the above, while the bot missed the event | The view's next edit returns "Unknown Message" | none |
The third row is why a bot that trims its intents still retires deleted panels:
refresh() nulls the message, and a task of its own then calls
on_message_gone() and tears the view down through on_message_delete(), unless
the hook sent the view again. Without the message intents, a deleted panel
keeps its view until its next edit, its timeout, or a restart.
A plain discord.Client, unlike commands.Bot, has no listeners the library can
add, so only the third row reaches its views.
Override the hooks for custom behavior:
class MyView(StatefulLayoutView):
async def on_message_gone(self):
# Reconcile your own record of the message. No exit needed: the
# library tears the view down right after this returns.
await forget_panel(self.persistence_key)
async def on_message_delete(self):
print(f"View {self.id} message was deleted")
await super().on_message_delete()
Once a view is torn down, a later signal for the same deletion finds nothing to
do. The cleanup listeners are 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.