ST

MINIMAL REACTIVE STATE FOR PYTHON

st is a small, typed reactive state runtime for Python. Values are plain objects with a .value, derived state is lazy, and side effects are owned explicitly instead of hidden behind framework magic.

Sources notify computations, computations track their sources, and scopes make ownership visible. Updates are synchronous and deterministic by default, so the mental model stays close to the code you write.

> uv sync
> uv run python examples/shopping_cart.py
zero runtime dependencies. Python >= 3.13.

THE GRAPH

Three primitives form the reactive graph: state for mutable sources, computed for lazy derived values, and effect for tracked side effects.

Computations replace their source set on each run. Unchanged values short-circuit equality checks, so subscribers only hear about meaningful changes.

st runtime

    read .value tracks source                 read .value tracks source
+------------------------+               +------------------------+
| state(count)           | ------------> | computed(double)       |
| mutable source         |               | lazy cache, dirty flag |
+-----------+------------+               +-----------+------------+
            |                                        |
            | write .value                           | read derived value
            | equality check                         v
            | notify subscribers          +------------------------+
            +--------------------------> | effect() / watch()     |
                                         | side effects + cleanup |
                                         +-----------+------------+
                                                     |
                                                     | owned by
                                                     v
                                         +------------------------+
batch() groups writes --------------->  | scope() / dispose()    |
flush once, then run dependents          | explicit lifetime      |
                                         +------------------------+
read collects sources. write marks dependents dirty. ownership stays visible.
from st import computed, effect, state

count = state(1)
double = computed(lambda: count.value * 2)
seen: list[int] = []

effect(lambda: seen.append(double.value))

count.value = 2

assert double.value == 4
assert seen == [2, 4]

EXPLICIT OWNERSHIP

Effects, computed values, watchers, and cleanup callbacks can belong to a scope. A scope gives reactive work a visible lifetime.

Cleanup is ordinary Python: register it with on_cleanup, stop resources with dispose, or let the owning scope close them.

from st import effect, on_cleanup, scope, state

count = state(1)
values: list[int] = []
owner = scope()

def setup() -> None:
    effect(lambda: values.append(count.value))
    on_cleanup(lambda: values.append(-1))

owner.run(setup)
count.value = 2
owner.dispose()

assert values == [1, 2, -1]

WATCH WHAT YOU MEAN

watch tracks an explicit source and reports new, old, and optional cleanup. Custom equality keeps selector-style watches focused on meaningful changes. batch coalesces updates and flushes effects once.

When reads should not become dependencies, use untrack or peek. The graph stays precise because dependency collection is something you can see.

from st import batch, state, watch

count = state(1)
events: list[tuple[int, int | None]] = []

watch(
    lambda: count.value,
    lambda new, old: events.append((new, old)),
    equals=lambda old, new: old % 2 == new % 2,
)

with batch():
    count.value = 3
    count.value = 4

assert events == [(4, 1)]
deterministic updates, one flush, no scheduler surprise.

ONE SMALL SURFACE

The public API is deliberately compact. The runtime uses structural typing internally and PEP 695-style generics for State[T] and Computed[T].

API Purpose
state(value, *, equals=...) Create mutable reactive state.
computed(fn) Create lazy derived state.
effect(fn) Run a side effect with automatic source tracking.
watch(source, callback, *, immediate=False, equals=...) Watch an explicit source with new, old, custom equality, and cleanup.
readonly(value) Expose a read-only view of state or computed state.
batch() Coalesce updates and flush effects once.
untrack() / peek(value) Read reactive values without collecting sources.
scope() / dispose(value) Own and stop reactive resources explicitly.

SHOPPING CART EXAMPLE

The example app combines cart lines, coupon validation, checkout readiness, scoped cleanup, untracked analytics snapshots, and batched updates.

It is intentionally plain Python: dataclasses, dictionaries, functions, and a reactive layer small enough to inspect in one sitting.

cart = state({})
coupon_code = state("")
customer_tier = state("standard")

item_count = computed(lambda: sum(line.quantity for line in cart.value.values()))
subtotal = computed(lambda: sum(line.subtotal for line in cart.value.values()))

summary = computed(build_summary)
can_checkout = computed(
    lambda: item_count.value > 0
    and all(line.quantity <= INVENTORY.get(line.sku, 0) for line in cart.value.values())
)
full source: examples/shopping_cart.py

ROADMAP

Core infrastructure is in place: dispose, untrack, peek, batch, and cleanup.

  • scheduling - custom effect schedulers, queued flush, next_tick
  • debugging - runtime type guards, source inspection, subscriber inspection
  • labels - optional names for state, computed values, effects, and scopes