State Management

Wijjit ships with a purpose-built wijjit.core.state.State class that behaves like a dictionary, emits change notifications, and integrates tightly with the rendering pipeline. Mastering the state layer makes it easy to reason about reactivity, derived data, and background work.

State fundamentals

State subclasses collections.UserDict so it exposes familiar dict semantics while adding:

  • Attribute accessstate.greeting mirrors state["greeting"]; templates can use either form.

  • Any key name – including ones that share a name with a State method (items, keys, get, data…). Templates resolve {{ state.items }} to your value; in Python, reach those keys with state["items"]. See Keys that share a name with a State method.

  • Change detection__setitem__ and __setattr__ compare the new value to the previous one; callbacks fire only when a value actually changed.

  • Watchers – arbitrary functions can subscribe to all changes (state.on_change) or to a specific key (state.watch("username", callback)).

  • Async support – callbacks may be async def; Wijjit tracks the resulting tasks so the event loop can await them without leaking coroutines.

In-place mutation and the order that matters

State detects reassignment, not in-place mutation. state["todos"].append(x) never goes through __setitem__ at all, so no watcher fires and nothing re-renders.

The rule that always works is build the new container first, then assign:

app.state["todos"] = [*app.state["todos"], new_todo]      # fires

todos = list(app.state["todos"])   # copy FIRST
todos.append(new_todo)             # mutate the copy
app.state["todos"] = todos         # fires

Order is everything, and the failing order is silent. If you mutate the stored list first and try to reassign afterwards, change detection has nothing left to compare against - the value it would compare with has already been mutated:

app.state["todos"].append(x)                       # mutates the stored list
app.state["todos"] = list(app.state["todos"])      # SILENT: value-equal copy

That copy is a new object, but it is equal to the already-mutated original, so it is indistinguishable from a no-op write. There is no way for State to catch it.

The one escape hatch, if you have already mutated in place, is to assign the same object back:

app.state["todos"].append(x)
app.state["todos"] = app.state["todos"]   # fires (and logs a warning)

Assigning the same object back always fires a change (and logs a warning nudging you toward the build-first pattern). Reach for it only to recover from an in-place mutation you cannot easily undo; prefer the immutable form.

Common mutation patterns

Single key updates

app.state["status"] = "Saving…"
app.state.count += 1            # attribute style

Batch updates

Each assignment normally fires its own change callbacks immediately. To coalesce a group of related changes into a single notification, use State.batch_update() (sync) or State.async_batch_update() (async). Inside the context manager intermediate callbacks are suppressed, and on exit each key that actually changed (comparing original old vs. final new value) triggers callbacks once:

def update_profile(name: str, email: str) -> None:
    state = app.state
    with state.batch_update():
        state["status"] = "Saving…"
        state["profile"] = {**state["profile"], "name": name, "email": email}

In async code, prefer async with state.async_batch_update(): so async callbacks are awaited before the context exits.

Multi-key and whole-state updates

To set several keys from a dict in one call, use update() (each changed key fires its callbacks, and reserved names are validated):

app.state.update({"name": name, "email": email}, verified=True)

To replace the entire state (or clear it), use reset(). It fires change callbacks for every key that was removed or modified:

app.state.reset({"count": 0, "items": []})   # replace all keys
app.state.reset()                             # clear everything

Watching specific keys

def log_change(key, old, new):
    logger.info("State %s changed from %r to %r", key, old, new)

app.state.watch("username", log_change)

If you need to react to multiple keys, register multiple watchers or use on_change and branch on key. Watchers run synchronously unless they are async coroutines, in which case Wijjit schedules them on the running loop.

To stop listening, use the removal counterparts. unwatch(key, callback) drops one watcher (or every watcher for the key if callback is omitted), and off_change(callback) unregisters a global on_change callback:

app.state.unwatch("username", log_change)   # or unwatch("username") for all
app.state.off_change(log_change)

Keys that share a name with a State method

State is a mapping and an object, so a handful of key names – items, keys, values, get, data, update, copy, pop, on_change, watch, and the rest of the public API – also name a method. Such keys are perfectly legal. Only Python’s attribute syntax is ambiguous for them, and Wijjit resolves that ambiguity explicitly rather than banning the names:

app.state["items"] = [1, 2, 3]   # always works
app.state.items                  # the bound method - attribute lookup wins
app.state.items = [1, 2, 3]      # raises StateKeyError, pointing you at subscript
{{ state.items }}                {# your list: templates look the key up first #}
{% for x in state.items %}...{% endfor %}

In templates the key always wins, because Wijjit’s Jinja environment checks state for the key before falling back to attribute lookup. That fallback is what keeps {% for k, v in state.items() %} and {{ state.get('k', d) }} working in apps that do not define keys by those names.

The one cost, and the reason to think twice before naming a key after a method: in an app that defines state["get"], the expression {{ state.get('k') }} resolves to that value and then fails with a TypeError because it is not callable. Use {{ state['k'] }} or the default filter there instead.

Async and background work

Long-running operations should not block the event loop. Typical pattern:

async def refresh_data():
    app.state.loading = True
    try:
        data = await api.fetch()
        app.state["items"] = data   # 'items' shadows a method: use subscript
    finally:
        app.state.loading = False

State always runs watchers on the app’s event-loop thread, whether the watcher is sync or async and no matter which thread performed the write. If you assign from a background thread, the callbacks are handed to the loop rather than run on the worker. So it is safe to do state[key] = value from a worker thread, and safe to set RUN_SYNC_IN_EXECUTOR = True (with an optional EXECUTOR_MAX_WORKERS) to run heavy synchronous handlers off the main loop and have them update state afterward.

Two caveats. Because a write from a worker thread hands its callbacks to the loop, they run slightly later than the assignment rather than inline with it - so do not assume the UI has repainted by the time state[key] = value returns. And a State used with no running event loop (bare, or in a sync-only script) simply invokes callbacks inline on the calling thread, since there is no loop to hand them to.

A plain assignment fires async watchers but does not wait for them (they run as background tasks). When you need the callbacks to finish before continuing, use await state.set_async(key, value), which sets the value and awaits every triggered callback. To drain any still-pending async callbacks (e.g. before shutdown or in a test), await state.flush_pending_async().

Derived & computed data

Keep frequently-used derived values in either:

  • State – update the derived key inside a watcher whenever its inputs change.

  • View ``data`` callables – compute values on demand when the view renders.

Example – maintain completed_count whenever todos changes:

def derive_counts(key, old, new):
    if key == "todos":
        completed = sum(1 for todo in new if todo["done"])
        app.state.completed_count = completed

app.state.watch("todos", derive_counts)

In templates you can reference state.completed_count without recomputing. If the derived value depends on multiple keys, register watchers for each or compute it inside the view’s data function where you have access to the entire state snapshot.

Validation & error reporting

A common pattern is to store validation errors in state and render them near input elements:

errors = {}
if not state.username.strip():
    errors["username"] = "Username required"
if len(state.password) < 8:
    errors["password"] = "Password must be 8+ chars"
app.state.form_errors = errors

Template snippet:

{% if state.form_errors.username %}
  {{ state.form_errors.username }}
{% endif %}

Remember to clear or overwrite errors after successful submission. State decides whether to fire watchers by comparing the new value to the old with !=, so assigning a fresh form_errors dict whose contents differ from the previous one ensures watchers run.

Refreshing manually

Most state mutations trigger renders automatically. Rare cases (timers, animations) may update off-loop; call app.refresh() or set app.refresh_interval to request periodic repaints (useful for spinners and clocks).

Testing tips

  • Instantiate State directly in unit tests to verify watchers and validation logic without booting a full Wijjit app.

  • Use pytest.mark.asyncio for async watchers and await state.flush_pending_async() to deterministically drain pending async callbacks (preferred over asyncio.sleep(0)).

  • Snapshot rendered templates (see syrupy integration) after mutating state to ensure UI changes match expectations.

Next steps: dive into Templates & Tags to see how state binds to UI elements, then Event Handling to wire user input back into your data model.