Docs
Quick start
Yoltra is a framework-agnostic, event-driven state container with fine-grained
reactive subscriptions. You write events; reducers fold them into state; a
structural diff notifies subscribers on exactly the paths that changed; effects
run afterwards. No proxies, no magic — just diff(prev, next) → paths → subscribers.
#Install
Yoltra requires CPython ≥ 3.11 and has zero runtime dependencies.
pip install yoltra#Your first store
from yoltra import create_store
store = create_store("Todos")
# A reducer owns a slice of state and folds matching events into it.
@store.reducer(name="todos", state={"items": []}, when={"keys": [("ui", "add")]})
def todos(prev, event):
return {"items": [*prev["items"], {"title": event.payload, "done": False}]}
# Emit an event. State is committed synchronously — the moment emit() returns.
store.emit("ui", "add", "Buy milk")
print(store.get_state()["todos"]["items"])
# [{'title': 'Buy milk', 'done': False}]Three things happened: emit("ui", "add", "Buy milk") created an event; the
todos reducer (registered for the ("ui", "add") key) produced the next slice;
and the store committed it before emit returned.
#React to changes with connect
connect subscribes to a dotted path under a slice. The store diffs each
change and fires only the subscribers whose path changed:
store.connect(
reducer="todos",
property="items",
handler=lambda change: print("items changed:", change.new_value),
)
store.emit("ui", "add", "Walk the dog")
# items changed: [{'title': 'Buy milk', ...}, {'title': 'Walk the dog', ...}]Paths support globs: * matches one segment, ** matches any depth. Here's a
fresh store that also handles renames, with a monitor on items.*.title:
store = create_store("Todos")
@store.reducer(name="todos", state={"items": []},
when={"keys": [("ui", "add"), ("ui", "rename")]})
def todos(prev, event):
if event.type == "add":
return {"items": [*prev["items"], {"title": event.payload, "done": False}]}
# rename: (index, new_title)
i, title = event.payload
items = list(prev["items"])
items[i] = {**items[i], "title": title}
return {"items": items}
store.connect(reducer="todos", property="items.*.title",
handler=lambda c: print(f"title {c.path}: {c.old_value!r} → {c.new_value!r}"))
store.emit("ui", "add", "Buy milk")
store.emit("ui", "rename", (0, "Buy oat milk"))
# title items.0.title: 'Buy milk' → 'Buy oat milk'Gotcha worth knowing early: adding an item changes the list's length, so
the diff marks the whole items path (not items.0.title). An in-place
edit of an existing item (same length) yields the precise leaf path. This falls
straight out of the structural diff and is covered in the developer guide.
#Run side effects
Effects run after an event commits — perfect for persistence, logging, or kicking off the next action. They can be sync or async:
@store.effect(when={"keys": [("ui", "add")]})
def persist(event, get_state, emit):
save_to_disk(get_state()["todos"]["items"]) # your codeemit() returns an EmitHandle. From synchronous code you can block until
the effect finishes; from async code you can await it:
handle = store.emit("ui", "add", "Buy milk")
handle.wait() # sync: block until this event's effects finish
# or, inside async code:
# await store.emit("ui", "add", "Buy milk")#Guard writes with middleware
Middleware runs before reducers and can veto an event by returning False:
@store.middleware()
def reject_empty(state, event, emit):
return not (event.type == "add" and event.payload == "")
store.emit("ui", "add", "") # vetoed — no item added#Where to next
- Developer guide — the full model and every feature.
- Migrating from
@yoltra/core— if you know the TS version. - Reactive blackboard for agents — the agentic use case.