API: State Store¶
get_store()¶
Returns the singleton StateStore instance.
StateStore¶
Methods¶
dispatch(action_type, payload=None, source_id=None)¶
Dispatches an action through the middleware pipeline and into the matching reducer.
action_type(str): The action type (e.g.,"COUNTER_UPDATED")payload(dict, optional): Data for the actionsource_id(str, optional): ID of the dispatching view. The action dict's own field is namedsource
Subscriber failures are caught and logged internally -- dispatch() does not raise from subscriber errors.
subscribe(subscriber_id, callback, action_filter=None, selector=None)¶
Registers a subscriber for state change notifications. Views auto-subscribe during __init__ and tear down through their own lifecycle hooks; direct calls from user code are rare.
subscriber_id(str): Unique ID for the subscribercallback(callable): Called on matching state changes. Eitherasync defor a plaindef.action_filter(set[str], optional): Only notify for these action typesselector(callable, optional): Synchronous function(state) -> valuethat extracts a slice. Subscriber is only notified when the selected value changes. A selector that raises degrades to notifying on every action, and is reported once.
Subscribing again under the same id replaces the earlier registration. A view's id raises ValueError: replacing a view's subscription would stop it rendering.
unsubscribe(subscriber_id)¶
Removes a subscriber added with subscribe(). An id that is not subscribed is ignored, so a cleanup path can call it more than once. A view's id raises ValueError: a view leaves the store when it closes, so call its exit() instead.
state¶
Public attribute holding the current state dict. Read-only by convention; mutate state through dispatch(), not by assignment.
Persistence The store has no direct persistence methods. Wire persistence through
PersistenceMiddleware, installed viasetup_middleware. The middleware'sinitialize(store)pass stashes aPersistenceManageronstore.persistence_managerfor runtime access (rehydrate is blocking; writes are debounced).
has_middleware(middleware_cls) -> bool¶
Returns True when an instance of the given middleware class is installed. Used by setup_middleware to gate duplicate installs; available to callers that need to branch on middleware presence.
from cascadeui import UndoMiddleware
if store.has_middleware(UndoMiddleware):
# undo/redo buttons will work
...
Install path
store._add_middlewareandstore._remove_middlewareare internal. User code installs middleware throughsetup_middleware, which gates duplicates viahas_middlewareand awaits each middleware'sinitialize(store)method in order.
batch()¶
Returns an async context manager that coalesces every dispatch inside the
block into a single subscriber notification at exit. Reducers run
immediately -- state is live throughout the block so later dispatches can
read earlier writes. At exit, one synthetic BATCH_COMPLETE action is
dispatched carrying the full action list.
async with store.batch():
await store.dispatch("ACTION_A", payload_a)
await store.dispatch("ACTION_B", payload_b)
Transitivity. Any helper routing through store.dispatch()
(update_session, dispatch_scoped, view-level dispatch, and the
internal _register_state) participates in the active batch. Nested
batch() blocks absorb into the outermost batch; no intermediate
BATCH_COMPLETE is emitted.
Scope. A batch belongs to the task that opened it. Nesting absorbs
within one task, while two tasks batching concurrently keep separate
queues and each fires its own BATCH_COMPLETE with its own source_id.
A dispatch from an unrelated background task is never swallowed by a
batch it knows nothing about.
Because reducers commit inline, a dispatch from another task while a
batch is open is notified against state that may include that batch's
committed prefix. Every such state is a complete post-action state
(reducers replace whole top-level keys, so there is no half-written value
to observe), and BATCH_COMPLETE re-notifies with the final state when
the batch closes.
Exception semantics. batch() is a notification gate, not a
transaction. Reducers run inline, so the dispatches before a raise have
already changed state. Those are announced in one BATCH_COMPLETE and
recorded on the undo stack, and the exception then propagates: state,
subscribers, and undo all agree on the prefix that committed. Anything
that must not survive the raise is rolled back by the block itself.
Profiling. Per-dispatch profiling samples are suppressed inside a
batch (individual notify_ms would be zero); the BATCH_COMPLETE
notification fires one sample for the whole batch.
See the State Management guide for typical scenarios.
on(event_name, callback)¶
Registers an event hook. event_name is a snake_case name (e.g., "view_created") that maps to the action type VIEW_CREATED.
off(event_name, callback)¶
Removes an event hook.
scope_key(scope, *, user_id=None, guild_id=None) -> Optional[str] (staticmethod)¶
Builds a scope key string ("user:123", "guild:456", "user_guild:123:456", "global"), or None when the scope's required ids are missing. The single writer of the scope-key format: the scoped-state readers, the instance-limit index, and the sync availability pre-check all route through it, so the format is defined once. 0 is a legitimate id and is never treated as missing; a caller that wants falsy ids treated as absent normalizes with or None first.
get_scoped(scope, *, slot_name="scoped", **identifiers)¶
Returns scoped state for the given scope type and ids. Reads from the live self.state. slot_name selects the named bucket under state["application"]; views that set scoped_slot pass their own.
get_scoped_from(state, scope, *, slot_name="scoped", **identifiers) (staticmethod)¶
Reads a scoped slice from an explicit state dict rather than the live store. Intended for @computed selectors (which receive state as input) and custom reducers (which mutate deep-copied state). Using store.get_scoped() inside a reducer would bypass the deep-copied state -- get_scoped_from(state, ...) keeps the read aligned with what the reducer is mutating.
iter_scoped(state, scope, *, slot_name="scoped", **filter_ids) (staticmethod)¶
Iterates the named scoped slot in an explicit state dict, yielding (identifiers, data) pairs where identifiers is the parsed id mapping, e.g. ({"user_id": 123}, data). Unsupplied identifiers act as wildcards and malformed keys are skipped. Pass store.state for a live read or a reducer's state for a consistent one. Used by hub views that aggregate across many users or guilds (leaderboards, dashboards) without reaching into state["application"]["scoped"] directly.
set_scoped(scope, data, *, slot_name="scoped", **identifiers)¶
Sets scoped state for the given scope type and ids. A reducer running in another task commits first, and this write lands after it.
get_active_views() -> Mapping[str, Any]¶
Returns a read-only MappingProxyType over the internal active-view
registry (view_id -> view instance). The returned mapping is live,
not a snapshot (later registrations and teardowns show through), but
mutation raises TypeError, so the privacy boundary stays intact.
from cascadeui import get_store
store = get_store()
for view_id, view in store.get_active_views().items():
print(f"{view_id}: {type(view).__name__}")
Used by DevToolsCog to avoid reaching into store._active_views
directly. User code that needs to iterate or count live views can
consume the same accessor.
get_active_view(*, persistence_key) -> Optional[Any]¶
Returns the live view holding persistence_key, or None. Use it to reach
a panel's instance from outside the view (a scheduler refreshing a posted
panel, a command retiring one) instead of keeping your own registry of
instances.
from cascadeui import get_store
panel = get_store().get_active_view(persistence_key=f"roles:panel:{guild_id}")
if panel is not None:
await panel.reload()
- Matches the
persistence_key=the view was constructed with. A view given no key holds none, so a view id never matches; the argument is keyword-only for that reason. - A finished view (exited, or stopped without exiting) is never returned,
and neither is a view a
push()orpop()is still bringing in: until its edit lands, the view it would replace is the one on screen. - When several unfinished views hold the key, the one the stored registration
points at is returned, which is the panel on screen: during a swap under
retire_previous_on_send = Falsethat is the old panel until the new one registers. With no registration (a non-persistent view keyed for a slot), the most recently registered holder is returned. - Works whether or not persistence is installed.
- Costs the same however many views are live: the store indexes views by key and by registration message as they register, so a refresh loop can resolve every panel it owns without walking the registry once per panel.
- A persistent panel that has pushed a child view still holds its key, through
the view now on its message: navigation replaces the instance, so the
destination carries the registration's message id without the key, and that
view is what comes back. It is not always a persistent view. The same rule
decides whether
prune_registrywarns, so the pre-flight and the warning agree.
merge_scoped(state, scope, data, *, slot_name="scoped", subkey=None, **identifiers)¶
Reducer-side writer that merges data into the scope bucket and returns state. Completes the scoped family alongside get_scoped_from and iter_scoped. Used inside custom reducers to decode the canonical {"scope", "identifiers", "data"} payload emitted by view.dispatch_scoped_as(...).
Reducer, computed, view-registry, and participant plumbing are internal
_register_reducer,_register_computed,_register_view/_unregister_view/_get_active_views, and_register_participant/_unregister_participantare single-underscore internals. User code reaches the same behavior through public entry points:@cascade_reducerand@computeddecorators for registration, andStatefulView.send()/exit()/register_participant()for view lifecycle.
Properties¶
state(dict): The current state treehistory(list): Recent dispatched actions, capped byhistory_limit(default 100)computed(dict-like): Access computed values by name (e.g.,store.computed["total_votes"])
@cascade_reducer(action_type)¶
Decorator that registers a reducer function for a custom action type.
@cascade_reducer("MY_ACTION")
async def my_reducer(action, state):
# State is already deep-copied by the decorator -- mutate directly
state["my_key"] = action["payload"]["value"]
return state
The return state is required, not stylistic: the store commits whatever the
reducer hands back. Falling off the end raises TypeError naming the reducer
and the action, which the store logs while keeping the previous state.
Reducers run one at a time, from reading the state to committing the result,
so a reducer that awaits holds up every other dispatch until it returns. A
dispatch kept waiting 30 seconds logs a warning naming the reducer it waits
for. dispatch() called from inside a reducer, or from a task the reducer
starts while it runs, raises RuntimeError.
@computed(selector)¶
Decorator that registers a memoized derived value on the global store. The
decorated function's __name__ becomes the key in store.computed.
selector(callable):(state) -> valuethat picks the input slice- The decorated function receives the selector's output and returns the derived value
- Result is cached until the selector output changes
@computed(selector=lambda s: s.get("application", {}).get("votes", {}))
def vote_totals(votes):
return {lang: len(voters) for lang, voters in votes.items()}
# Access from any view:
totals = store.computed["vote_totals"]
ComputedValue¶
The object created by @computed. Rarely used directly.
Methods¶
get(state) -> Any¶
Returns the cached value, recomputing only if the selector output changed since the last call.
invalidate()¶
Forces recomputation on the next get() call, regardless of whether the
selector output changed.
setup_middleware(*middlewares, store=None)¶
Top-level async helper that installs middleware into the store's dispatch chain. Each middleware is installed once (guarded by store.has_middleware(type(mw))), then its async initialize(store) method is awaited if one is defined.
from cascadeui import (
LoggingMiddleware,
PersistenceMiddleware,
UndoMiddleware,
setup_middleware,
)
from cascadeui.persistence import SQLiteBackend
class MyBot(commands.Bot):
async def setup_hook(self):
await setup_middleware(
LoggingMiddleware(),
PersistenceMiddleware(backend=SQLiteBackend("data.db"), bot=self),
UndoMiddleware(),
)
Parameters
*middlewares-- middleware instances in the order they should appear in the dispatch chain.store-- optional explicit store. Defaults to the global singleton fromget_store().
Idempotency. initialize is always awaited, even when the middleware is already installed. Middlewares contract their initialize methods as idempotent (a later call does not repeat startup work), so the always-await policy is safe. A call that passes a new instance of a class already installed initializes the installed one and does not use the new instance. A restart in the same process builds a new bot object (discord.py cannot log a closed one in again), and its setup_hook makes that call: the installed PersistenceMiddleware takes the new bot from the new instance, reopens the persistence the old bot's close shut, and restores through the new bot the persistent panels the old one left. A plain function or method middleware is matched by equality rather than class (the same function, or the same method of the same object), since every function has the same class.
ActionCreators¶
Static helper methods for building action payloads. Used internally; you can also call store.dispatch() or view.dispatch() directly with a type and payload dict.
For the full list of built-in action types, payload shapes, and reducer behavior, see Built-in Actions.
Type Aliases¶
StateData¶
Dict[str, Any] -- the canonical state-dict shape passed to reducers, selectors, and middleware. Exported from the package root for type hints on custom reducers and @computed selectors.
from cascadeui import StateData, cascade_reducer
@cascade_reducer("MY_ACTION")
async def my_reducer(action: dict, state: StateData) -> StateData:
state.setdefault("application", {})["counter"] = action["payload"]
return state
Additional type aliases (Action, ReducerFn, SubscriberFn, MiddlewareFn, SelectorFn, HookFn) live in cascadeui/state/types.py and can be imported directly from there when needed.