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
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 |
+------------------------+
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)]
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())
)
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