Skip to content

API: State Store

get_store()

Returns the singleton StateStore instance.

from cascadeui import get_store
store = get_store()

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 action
  • source_id (str, optional): ID of the dispatching view. The action dict's own field is named source

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 subscriber
  • callback (callable): Called on matching state changes. Either async def or a plain def.
  • action_filter (set[str], optional): Only notify for these action types
  • selector (callable, optional): Synchronous function (state) -> value that 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 via setup_middleware. The middleware's initialize(store) pass stashes a PersistenceManager on store.persistence_manager for 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_middleware and store._remove_middleware are internal. User code installs middleware through setup_middleware, which gates duplicates via has_middleware and awaits each middleware's initialize(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() or pop() 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 = False that 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_registry warns, 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_participant are single-underscore internals. User code reaches the same behavior through public entry points: @cascade_reducer and @computed decorators for registration, and StatefulView.send() / exit() / register_participant() for view lifecycle.

Properties

  • state (dict): The current state tree
  • history (list): Recent dispatched actions, capped by history_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) -> value that 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 from get_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.