Quick Start¶
Build a working stateful counter in 5 minutes. This tutorial introduces the core concepts one at a time -- by the end, the full data flow pattern is clear.
Prerequisites¶
- Python 3.10+ with discord.py 2.7+ installed
- CascadeUI installed
- A Discord bot token and a test server
Step 1: Build the View¶
A view is a single UI screen backed by the state store. V2 views
(StatefulLayoutView) use Discord's container-based component system -- the
component tree IS the message content:
import discord
from discord.ui import ActionRow
from cascadeui import (
StatefulButton,
StatefulLayoutView,
StateStore,
card,
key_value,
)
class CounterView(StatefulLayoutView):
# Access control: only the user who opened this counter can click.
owner_only = True
# Instance control: one counter per user; opening a second replaces
# the first instead of stacking duplicates.
instance_limit = 1
instance_scope = "user"
instance_policy = "replace"
# State scope: the count is stored per user under
# ``state["application"]["scoped"]["user:<id>"]``. Same user gets
# the same counter across every server that shares this bot.
state_scope = "user"
# Reactivity: subscribe to SCOPED_UPDATE so the view notices its own
# writes, and return the count from state_selector so the store only
# rebuilds when the number actually changes.
subscribed_actions = {"SCOPED_UPDATE"}
def state_selector(self, state):
# ``state`` is the post-reduce snapshot the store compares
# against; ``self.scoped_state`` would be stale here.
return StateStore.get_scoped_from(
state, "user", user_id=self.user_id
).get("count", 0)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.build_ui()
def build_ui(self):
"""Rebuild the component tree from current state."""
count = self.scoped_state.get("count", 0)
self.clear_items()
self.add_item(card(
"## Counter",
key_value({"Value": str(count)}),
color=discord.Color.blurple(),
))
self.add_item(ActionRow(
StatefulButton(
label="+1", style=discord.ButtonStyle.primary,
callback=self.increment,
),
StatefulButton(
label="-1", style=discord.ButtonStyle.danger,
callback=self.decrement,
),
))
self.add_exit_button()
async def increment(self, interaction):
count = self.scoped_state.get("count", 0)
await self.dispatch_scoped({"count": count + 1})
async def decrement(self, interaction):
count = self.scoped_state.get("count", 0)
await self.dispatch_scoped({"count": count - 1})
Key points:
state_scope = "user"stores the count under a per-user slot in the state tree. CascadeUI provides built-in scopes ("user","guild","user_guild","global"); writes viadispatch_scopedland in the right slot automatically.subscribed_actionsdeclares which action types this view receives on the state-change pub/sub. The default is an empty set (opt-in posture for performance), so a view that omits this attribute receives no notifications and its message never edits. Subscribe toSCOPED_UPDATEto react to scoped writes, or set toNoneto receive every action.state_selectornarrows the view's reactivity to one slice (here, the count). The store only fireson_state_changed()when the selector's return value changes between dispatches, so the view does not rebuild on unrelated state churn.dispatch_scoped({"count": N})is the convenience layer: it writes intostate["application"]["scoped"]["user:<id>"]for the current scope without a custom reducer.build_ui()rebuilds the component tree from scratch. The defaulton_state_changed()callsbuild_ui()followed byrefresh()whenever the selector's value changes -- no manual callback wiring needed.
Step 2: Wire It Up¶
Register the view as a slash command:
from discord.ext import commands
bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
@bot.hybrid_command()
async def counter(ctx):
view = CounterView(context=ctx)
await view.send()
view.send() handles message creation, state registration, session tracking,
and message reference capture in one call.
The Data Flow¶
Here is what happens each time a button is clicked:
Click "+1" → increment() → dispatch_scoped({"count": N})
│
SCOPED_UPDATE reducer writes
state["application"]["scoped"]["user:<id>"]
│
subscribed_actions filter:
SCOPED_UPDATE in this view's set?
│
state_selector compares old vs new count
│
on_state_changed() fires
│
build_ui() → refresh()
│
Discord message edited ✓
This is the unidirectional data flow pattern -- every state change follows the same path. See Core Concepts for the full diagram.
If the message never updates, check subscribed_actions
The default for subscribed_actions is an empty set, which filters
every action out before notification. The most common quickstart
bug is dispatching an action whose type is not listed in the view's
subscribed_actions -- the reducer runs (state updates) but
on_state_changed never fires (message stays stale). Either add
the action type to the set, or set subscribed_actions = None to
receive every notification.
Next Steps¶
- Core Concepts -- the mental models that make everything click
- Views -- lifecycle, navigation, sessions, policies
- Components -- selects, modals, V2 builders, grid helpers
- State Management -- custom reducers, scoped slots, undo/redo, batching. Reach for
@cascade_reducerwhen your state shape outgrows the slot model (cross-view aggregations, complex transitions, derived data). - View Patterns -- pre-built forms, wizards, tabs, pagination