State Management¶
CascadeUI uses a Redux-inspired unidirectional data flow. All state lives in a single store, updated through dispatched actions and immutable reducers. See Core Concepts -- Data Flow for the architecture diagram, and Core Concepts -- State Topology for the full state dict structure.
The State Store¶
The StateStore is a singleton:
Actions and Dispatch¶
Actions are plain dicts with type, payload, source, and timestamp.
Dispatch them through the store or a view:
# From the store
await store.dispatch("MY_ACTION", {"key": "value"})
# From a view (adds self.id as source)
await self.dispatch("MY_ACTION", {"key": "value"})
Subscriber failures don't propagate
dispatch() wraps subscriber callbacks in a safe handler that catches and
logs exceptions. Post-dispatch logic runs regardless:
Built-in Actions¶
CascadeUI dispatches these automatically. For payload shapes, reducer behavior, and middleware interaction, see the Built-in Actions API reference.
| Action | When |
|---|---|
VIEW_CREATED |
View registered with the store |
VIEW_UPDATED |
View state modified |
VIEW_DESTROYED |
View cleaned up (exit or timeout) |
SESSION_CREATED |
New user session begins |
SESSION_UPDATED |
Session data updated via update_session() |
NAVIGATION_PUSH |
View pushed onto nav stack |
NAVIGATION_POP |
View popped from nav stack |
NAVIGATION_REPLACE |
View replaced (one-way) |
SCOPED_UPDATE |
Scoped state updated |
COMPONENT_INTERACTION |
Button or select clicked |
MODAL_SUBMITTED |
Modal form submitted |
PERSISTENT_VIEW_REGISTERED |
PersistentView sent and tracked |
PERSISTENT_VIEW_UNREGISTERED |
PersistentView removed |
UNDO / REDO |
Undo/redo performed |
BATCH_COMPLETE |
Batch of actions finishes |
Custom Reducers¶
Most state-management needs are covered by Application Slots
and Scoped State below -- the convenience layer that
dispatch_scoped writes through. Reach for @cascade_reducer when the
state shape genuinely outgrows the slot model: cross-view aggregations,
complex transitions, derived data that depends on multiple slots, or
custom action types that need their own dispatch grammar.
Reducers transform state in response to actions. @cascade_reducer handles
deep-copying automatically -- mutate and return directly:
from cascadeui import cascade_reducer
@cascade_reducer("SCORE_UPDATED")
async def score_reducer(action, state):
scores = state.setdefault("application", {}).setdefault("scores", {})
scores[action["payload"]["user_id"]] = action["payload"]["score"]
return state
No copy.deepcopy needed
@cascade_reducer passes a deep copy. Mutate directly and return.
Reducers run one at a time
A reducer may await, and nothing else commits while it does: the store runs one reducer at a time, from reading the state to committing the result. A reducer that awaits slow work, such as a database call, holds up every other dispatch until it returns, and a dispatch kept waiting 30 seconds logs a warning naming it. Do slow work before dispatching and pass the result in the payload.
A reducer cannot dispatch. Its result replaces the state, so an action
dispatched from inside it would be lost, and dispatch() there raises
RuntimeError. So does a dispatch from a task the reducer starts while
it runs, such as a gather() around one, which would wait for the
reducer while the reducer waits for it. Unless the reducer catches the
error, its own change is not applied either. Dispatch the follow-up
after the first dispatch returns, or from an event hook.
Only writes made through a dispatch, and set_scoped(), wait for a
reducer running in another task. A write made in place on
store.state, such as access_slot(store.state, ...) in
seed_initial_state, lands at once and is lost if that reducer commits
after it.
Don't hold a reference to the snapshot across await
The dict passed to a reducer is a fresh deep copy that lives only for
that one call. Capturing the snapshot in a closure or a class
attribute and then reading it later returns a stale value -- the
store has already moved on to the next snapshot. Read the live state
via store.state (or a view's state_store.state) instead, and
re-read after every await boundary if values may have changed in
flight.
@cascade_reducer("ITEM_ADDED")
async def add_item(action, state):
items = state.setdefault("application", {}).setdefault("items", [])
items.append(action["payload"])
return state # OK -- the store keeps this and discards the input snapshot
# Outside reducers, never do this:
snapshot = store.state # stale immediately after any dispatch
await some_other_dispatch()
snapshot["application"]["items"] # reads the OLD copy, not current
Subscribers¶
Subscribe to state changes with optional filtering:
# All actions
store.subscribe("my-listener", my_callback)
# Specific action types only
store.subscribe("my-listener", my_callback,
action_filter={"SCORE_UPDATED", "GAME_ENDED"})
# From your own cleanup, such as a cog's cog_unload
store.unsubscribe("my-listener")
A subscriber lives until it is removed, so pair each subscribe() with an
unsubscribe() in the code that tears down whatever subscribed.
Selectors¶
Subscribe to a specific state slice -- the callback only fires when the selected value changes:
Views use selectors via state_selector():
class ScoreView(StatefulLayoutView):
subscribed_actions = {"SCORE_UPDATED"}
def state_selector(self, state):
return state.get("scores", {}).get(self.persistence_key)
Select what the view displays and nothing more. A theme resolved through
get_theme() rides the selector for you, so a panel tracking only its own
fields still repaints when the theme changes elsewhere. See
Dynamic Themes.
Views unsubscribe automatically on exit or timeout. subscribe() and
unsubscribe() refuse a view's id, since a view's subscription is what keeps
it rendering; close a view with exit().
Read the state argument, not the store
A selector must be a pure function of its state argument. A view
carries no .state attribute, so self.state raises AttributeError;
the store reports that once per subscriber and then degrades to
notifying on every action, which is safe but discards the filtering the
selector existed to do.
Correct: return state.get("scores", {})
Bug: return self.state_store.state.get("scores", {})
Reaching through self.state_store.state is the sharper trap inside a
reducer: the reducer mutates a deep copy while the store still holds
the pre-action values, so a read through the store sees data the reducer
has already moved past. The same rule applies to @computed selector
functions and to
StateStore.get_scoped_from(state, ...) calls made from reducers.
get_scoped_from is the staticmethod specifically designed to read
from the deep-copied state handed to a reducer, without reaching
back through self.state_store.get_scoped(...).
Concurrent Updates¶
When multiple dispatches target the same subscriber simultaneously (e.g. two
players clicking buttons at the same time), CascadeUI coalesces the
notifications automatically. The first notification runs on_state_changed
normally; any notifications that arrive while it is running set a pending flag
and return immediately. After the first completes, it re-runs once with the
latest store state to capture all pending changes.
This prevents concurrent build_ui() and message.edit() calls from racing
on the same view. Single-user views are unaffected - the lock is never
contended when only one dispatch runs at a time.
CascadeUI also preserves interaction routing during rebuilds. When build_ui()
calls clear_items(), old components stay routable in discord.py's internal
dispatch table until message.edit() completes the re-registration. Pending
interactions from other users are handled normally instead of being discarded.
Application Slots¶
An application slot is a named bucket under state["application"][name].
Every feature that wants a place in the state tree (a game's fleet
positions, a dashboard's visit counts, a wizard's in-flight step) gets one
slot and owns the shape inside it. Slot ownership is claimed by the first
write and inherited by everyone who reaches for the same name afterward.
Three helpers cover the read/write split so selectors never accidentally mutate authoritative state and reducers never hand-roll the walk:
| Helper | Purpose | Where it's safe |
|---|---|---|
access_slot(state, name, key=None, *, default_factory=None, persistent=False) |
Write/init. Auto-vivifies state["application"][name][key] and returns the stored value. |
Reducers (deep-copied state), seed_initial_state hook. |
read_slot(state, name, *path, default=None) |
Variadic pure read. Walks arbitrary depth via dict.get chains, never mutates. |
state_selector methods, @computed selectors, anywhere holding live store state by reference. |
slot_property(name, slot=..., key=..., default=...) |
Descriptor for the canonical three-level shape (application[slot][keyed][field]) read at attribute-access time. |
Class body of any view; reads self.state_store.state. |
from cascadeui import (
access_slot, read_slot, slot_property, cascade_reducer, StatefulLayoutView,
)
@cascade_reducer("SHIP_MOVED")
def reduce_ship_moved(action, state):
# access_slot walks state["application"]["battleship"][user_id],
# seeding {} on first access. Safe to mutate -- state is deep-copied.
bs = access_slot(state, "battleship", action["payload"]["user_id"])
bs["ships"] = action["payload"]["ships"]
return state
class FleetView(StatefulLayoutView):
state_scope = "user"
# Canonical three-level read: application["battleship"][user_id]["ships"]
ships = slot_property(
"ships", slot="battleship", key=lambda self: self.user_id, default=[]
)
def state_selector(self, state):
# read_slot is variadic -- pass any depth of path after the slot name.
return read_slot(state, "battleship", self.user_id, "ships", default=[])
Slots are in-memory by default. Passing persistent=True to access_slot
(or declaring persistent_slots = ("battleship",) on the view class)
registers the slot for write-through persistence -- every subsequent write
to that name is fanned out to disk by PersistenceMiddleware. See the
persistence guide for the full opt-in model.
A persistent slot's keys must be strings. JSON stores every key as a
string, so an int key such as the user_id above reads back as "42"
after a restart and a lookup by 42 misses. Key a persistent slot by
str(user_id); PersistenceMiddleware logs an ERROR naming the key when
it saves one that is not a string.
For paths deeper than three levels, the slot_property descriptor caps
out; declare a plain @property and call read_slot inside it:
class StatsView(StatefulLayoutView):
@property
def combat_wins(self):
return read_slot(
self.state_store.state,
"stats", self.guild_id, self.user_id, "combat", "wins",
default=0,
)
Scoped State¶
Isolate state per user, guild, or globally. Scoped data is stored under
state["application"]["scoped"], which means it shares the application
namespace's persistence plumbing -- opt a scoped slot in to disk with
persistent_slots = ("scoped",).
Setup¶
Set state_scope on the view class:
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 shared namespace |
None (default) |
N/A | No scoping -- dispatch_scoped unavailable |
state_scope vs instance_scope
Both accept the same string values but govern different subsystems.
state_scope = where data is stored. instance_scope = how instances are
counted. See Views -- Instance Management.
Reading¶
# 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()
# Override identifiers to read other users' data
other_user = self.user_scoped_state(user_id=other_id)
Writing¶
Scoped state merges with existing data.
Scoped data is in-memory by default
Scoped state is a Redux data-organization pattern, not a persistence
default. Writes land in state["application"]["scoped"] (or the named
bucket declared on scoped_slot) and stay in-memory unless the slot is
opted in. Two ways to persist:
- Class-level:
persistent_slots = ("scoped",)on the view class, orpersistent_slots = ("my_slot",)paired withscoped_slot = "my_slot". - Setup-level:
SlotPolicy(persistent=True)(plus optionalttl_days=N) in theapplicationconfig passed toPersistenceMiddleware.
Named scoped slots¶
Pass a scoped_slot class attribute to route a view's scoped reads and
writes into a named subsystem bucket instead of the default "scoped"
catch-all:
class BattleshipView(StatefulLayoutView):
state_scope = "user_guild"
scoped_slot = "battleship_stats"
persistent_slots = ("battleship_stats",)
Writes dispatched via self.dispatch_scoped(...) now land under
state["application"]["battleship_stats"][<scope_key>]. Multiple subsystems
co-exist cleanly under application without collision.
Scoped family helpers¶
StateStore exposes a small family for working with scoped data
consistently from views, reducers, and selectors:
| Helper | When to use |
|---|---|
self.scoped_state |
Property -- the current view's own scope slice. |
self.user_scoped_state(user_id=None) |
Read the "user" scope slice; defaults to this view's user_id. |
self.guild_scoped_state(guild_id=None) |
Read the "guild" scope slice; defaults to this view's guild_id. |
self.user_guild_scoped_state(user_id=None, guild_id=None) |
Read the "user_guild" composite scope slice. |
self.global_scoped_state() |
Read the "global" scope slice. |
self.dispatch_scoped(data) |
Write-through from a view -- merges into the scope slot. |
self.dispatch_scoped_as(action_type, data, *, scope=None, **ids) |
Dispatch a named action carrying the scoped payload, for a custom reducer that decodes it with merge_scoped. scope= overrides the view's state_scope. |
store.get_scoped(scope, **ids) |
Read from a live store (subscribers, devtools). |
StateStore.get_scoped_from(state, scope, **ids) |
Staticmethod -- read from the state arg handed to a reducer or @computed selector. |
StateStore.iter_scoped(state, scope, *, slot_name="scoped", **filter_ids) |
Staticmethod -- iterate every scope bucket under a slot, yielding (identifiers, data). |
StateStore.scope_key(scope, *, user_id=None, guild_id=None) |
Staticmethod -- build the scope-key string, or None when a required id is missing. |
StateStore.merge_scoped(state, scope, data, *, slot_name="scoped", subkey=None, **ids) |
Reducer-side writer -- mutates the deep-copied state in place. |
Cross-View Reactivity from Scoped Writes¶
dispatch_scoped() fires SCOPED_UPDATE, which other views don't subscribe
to by default. For live cross-view updates, dispatch a named action with a
custom reducer:
# This does NOT notify other views:
await self.dispatch_scoped({"theme": "dark"})
# This notifies all subscribers of "SETTINGS_UPDATED":
await self.dispatch("SETTINGS_UPDATED", {
"scope_key": f"user:{self.user_id}",
"changes": {"theme": "dark"},
})
| Method | Other views react? | Creates undo snapshots? |
|---|---|---|
dispatch_scoped() |
No | Yes |
dispatch("NAMED_ACTION") |
Yes | Yes |
A dispatch that writes no application slot and leaves shared_data alone
records nothing, since the entry could put nothing back and undo_limit
bounds the stack. Both methods participate in undo; a particular call
lands on the stack when it changes something.
dispatch_scoped and undo
dispatch_scoped() creates undo snapshots for views with
enable_undo = True -- scoped data lives under state["application"],
which the undo middleware snapshots. The limitation is cross-view
reactivity: SCOPED_UPDATE is not in any view's default
subscribed_actions, so other views don't rebuild automatically.
Named actions close that gap.
Session Data¶
Session data is metadata shared across all views in a push/pop navigation
chain. Views that share a session_id (all pushed/popped views inherit the
parent's session) share the same data dict. Unlike scoped state, session data
is ephemeral - it lives for the duration of the session and is not persisted
across restarts.
Independent invocations do not share a session by default
Every view invocation gets its own session_id because the auto-derived
identity includes a per-instance UUID suffix. Two users opening the same
view class, or the same user opening it twice, produce distinct
sessions with independent shared_data. Push/pop chains still stay on
one session because _navigate_to forwards session_id explicitly.
Views that want repeat-open continuity (undo history surviving
close-and-reopen, for instance) set session_continuity: ClassVar[bool] =
True on the class; see Five Pillars -- Session continuity
for the full contract.
Reading¶
Writing¶
update_session shallow-merges into the existing session data. Dispatches
SESSION_UPDATED, so views subscribing to that action are notified. Session
data changes are included in undo snapshots when enable_undo = True.
When to Use Session Data¶
Session data fills the gap between scoped state and instance attributes:
| Storage | Scope | Persists? | Shared across views? |
|---|---|---|---|
| Instance attributes | Single view | No | No |
| Session data | Push/pop chain | No | Yes |
| Scoped state | User/guild/global | Yes | Yes |
Typical use cases:
- Wizard configuration -- a multi-step wizard stores the chosen template or mode in session data so every step can read it without passing kwargs
- Game settings -- difficulty, turn timer, or house rules shared across all views in a game session
- Navigation breadcrumbs -- tracking which category was last visited in a settings hub, so the back button returns to the right place
Why Session Data Is Not Persisted¶
Session data is tied to session bookkeeping, and sessions are inherently
transient: a session_id identifies a live navigation window, not a durable
container. When the last view in a session exits, the session record (and its
shared_data) is removed by the reducer. Across a bot restart, no view is
attached to any session, so every session is effectively orphaned. Persisting
shared_data would write entries keyed to session_id values that no future
view will ever reattach to -- the data would live on as tombstones.
If you want cross-view data that does survive restarts, reach for scoped
state instead. Scoped slots are keyed by user_id, guild_id, or explicit
identifiers (all stable across restarts) and opt into persistence via
persistent_slots = ("scoped",). Two panels that need to coordinate across
restarts subscribe to the same scoped slot; a long wizard stores its
in-flight step in a scoped slot keyed to the user. The session layer keeps
its single responsibility (grouping live views) and the persistence layer
owns durable data with stable identity.
| Need | Where it lives |
|---|---|
| In-flight wizard step that survives restart | access_slot(state, "wizard_state", user_id, persistent=True) |
| Cross-panel settings shared per-guild | dispatch_scoped(scope="guild", ...) with persistent_slots = ("scoped",) |
| Temporary config passed between wizard steps in one sitting | shared_data via update_session() |
| Navigation breadcrumb for the current session | shared_data via update_session() |
Persistent views reattach with fresh session_id values by design -- a
restart is the end of liveness, and the new panel is a new session that
happens to use the same Discord message. Any data the panel cares about
lives in the scoped or application namespace, not in session bookkeeping.
Cross-View Reactivity from Session Writes¶
Other views can subscribe to SESSION_UPDATED to react to session data
changes:
class SubPageView(StatefulLayoutView):
subscribed_actions = {"SESSION_UPDATED"}
def state_selector(self, state):
session = state.get("sessions", {}).get(self.session_id, {})
return session.get("shared_data", {}).get("difficulty")
def build_ui(self):
difficulty = self.shared_data.get("difficulty", "normal")
# Rebuild UI based on the shared session config
Action Batching¶
Dispatch multiple actions atomically. Subscribers fire once after all actions complete:
async with self.batch():
await self.dispatch("VOTE_CAST", {"user_id": user_id, "delta": 1})
await self.dispatch("VOTE_LOG", {"entry": "User voted +1"})
# Single notification cycle fires here
Inside the block, each dispatch runs through middleware and the reducer
immediately -- state is current at every line, so later dispatches can read
what earlier ones wrote. Subscriber notifications and event hooks are deferred until the
block exits, where one synthetic BATCH_COMPLETE action fires the fan-out.
When to batch¶
Reach for batch() when a single user interaction triggers multiple state
changes that belong together logically. Each dispatch outside a batch fires
the full subscriber list, which means every connected view calls
on_state_changed() and queues a message.edit(). Six dispatches means
six rebuilds and six edits; one batch() collapses that to one.
Typical scenarios:
- Reset-all / apply-all: a "reset to defaults" button that writes six
settings at once. See
examples/v2_settings.pyfor the idiom. - Multi-step transitions: a game "start round" action that clears the previous turn, rolls a new seed, and resets per-player state.
- Wizard commit: a final "submit" step that dispatches one action per
collected field, then a terminal
WIZARD_COMPLETED.
batch() does not help with a single slow dispatch. If one reducer or one
subscriber is the bottleneck, batch() changes nothing -- use the
Performance tab to identify the phase, then tighten the
selector or cache with @computed.
Transitivity and helpers¶
Every helper that eventually calls store.dispatch() participates in the
outer batch: update_session(), dispatch_scoped(), and the internal
_register_state() used by send(). Nested batch() blocks absorb into
the outermost batch, so composing batched operations is safe:
async with self.batch():
await self.dispatch("ROUND_STARTED")
await self.update_session(seed=new_seed) # joins the batch
async with self.batch(): # absorbs, no inner fan-out
for pid in player_ids:
await self.dispatch_scoped({"last_move": None})
# One BATCH_COMPLETE fires here
Absorption is per task. Two coroutines batching at the same time keep
their own queues and each fires its own BATCH_COMPLETE, so running
independent work concurrently does not merge it into one notification.
A context is single-use. store.batch() returns a fresh one per call, and
re-entering one that has already run raises RuntimeError -- storing a
context on the view and reusing it across callbacks is the shape that hits
this. Call store.batch() again instead.
Exception handling¶
Reducers run inline (state is mutated as each dispatch returns), so
batch() is not a transaction; it is a notification gate. If the async
with block raises, the dispatches that already committed are announced in
one BATCH_COMPLETE and recorded on the undo stack, and the exception then
propagates. State, subscribers, and undo agree on the prefix that landed,
and undo() reverts it. Anything that must not survive the raise is rolled
back by the block itself. The library's own pipelines do exactly that, so an
aborted send() or push() still announces its create-then-destroy sequence
in one BATCH_COMPLETE -- but that sequence nets to no lasting state change,
and because view and navigation lifecycle actions are not undo-tracked it
leaves nothing on the undo stack either.
The library already batches its own pipelines¶
send(), push() / pop() / replace(), and cascade cleanup on attached
children already wrap their internal dispatches in a single batch. Callers
don't need to wrap await view.send() in a batch themselves -- the
navigation and lifecycle sequences produce one notification cycle each
regardless.
Batch and undo
With UndoMiddleware active, all actions in a batch produce a single
undo entry. Reverting "reset all settings" restores every slice in one
UNDO. The entry sits in the view's history where the batch's first
change was made, so a change another task makes from the same view
while the batch is open is undone first, and the entry restores each key
to its value from before whichever of the two wrote it first. A batch
that pushes or pops leaves its entry on the screen the panel moved to, as
does a change that lands after the push.
Event Hooks¶
React to state lifecycle events without subscribing:
async def on_interaction(action, state):
print(f"Component {action['payload'].get('component_id')} clicked")
store.on("component_interaction", on_interaction)
store.off("component_interaction", on_interaction)
Hook names map to action types: view_created → VIEW_CREATED,
component_interaction → COMPONENT_INTERACTION, etc.
Hooks vs subscribers vs middleware
- Middleware -- before the reducer, can modify/block actions
- Subscribers -- after the reducer, filtered by action type + selector
- Hooks -- after subscribers, read-only (logging, analytics)
Computed Values¶
Register memoized derived state that any view can read without recalculating.
The @computed decorator combines a selector (picks a state slice to watch)
with a compute function (transforms that slice). The result is cached and
only recomputed when the selector output changes.
from cascadeui import computed, get_store
@computed(selector=lambda s: s.get("application", {}).get("votes", {}))
def vote_totals(votes):
return {lang: len(voters) for lang, voters in votes.items()}
# Any view reads the same cached result
store = get_store()
totals = store.computed["vote_totals"]
How It Works¶
- On access (
store.computed["name"]), the selector runs against current state - If the selector output matches the last-seen value, the cached result returns
- If the selector output changed, the compute function runs and the result is cached
Recomputation is lazy -- it only happens when a view reads the value, not on every dispatch.
Computed vs state_selector()¶
@computed |
state_selector() |
|
|---|---|---|
| Scope | Global, shared across all views | Per-view instance |
| Purpose | Derive and cache a transformed value | Detect whether a view's relevant state changed |
| Access | store.computed["name"] |
Automatic (drives on_state_changed) |
| Caching | One cache per registered name | One cache per subscriber |
Use @computed when multiple views need the same derived value (totals,
rankings, aggregates). Use state_selector() when a single view needs to
filter which state changes trigger its rebuild.
Invalidation¶
Invalidation is automatic and there is no manual seam. Each read compares the selector's current output against the last one; when they differ the compute function re-runs, and when they match the cached value is returned. A value therefore cannot go stale while its own inputs are unchanged.
store.computed[name] returns the computed value, not the cache object.
/cascadeui reset clears every cache at once, which is the only blanket
invalidation the library offers.
Full Example¶
See v2_computed.py
for a complete poll example with computed totals and leader detection.
Undo/Redo¶
Enable undo/redo per 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()
Snapshots capture both state["application"] and the session's data dict.
This means dispatch_scoped() changes, update_session() changes, and custom
reducer changes all round-trip through undo/redo. Internal lifecycle actions
are excluded from undo tracking. New actions after an undo clear the redo
stack (standard semantics).
Undo puts back only what the action changed, key by key at any depth. Every user's scoped data shares one slot, so one user undoing a change leaves the writes other users, guilds, or features made in that slot as they are.
A batch() joins the history as one step when it ends. An undo() or
redo() called while another task's batch on the same view (or on the view
that pushed or popped to it) is still open waits for that batch, for at most
30 seconds, and then goes ahead with a warning. The wait is made again when
the undo reaches its reducer, so a batch that opened while a middleware held
the undo is waited for too. An undo whose state a middleware built before
that batch's writes is skipped with a warning rather than applied, since
reducing it would erase them. Undo again once the batch ends. An undo from
inside the batch, or from a task it started, runs at once.
One change is not recorded: replacing a value with an equal one of another
type, such as 1 with 1.0 or True, when nothing else in the same
dictionary changes. Undo compares each dictionary by equality before walking
it, because walking every unchanged dictionary value by value makes each
dispatch more than ten times slower once a slot holds a thousand users.
Action History¶
The store keeps a history of dispatched actions for debugging:
The DevTools Inspector visualizes this history on the History tab.