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.

bash
pip install yoltra

#Your first store

python
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:

python
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:

python
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:

python
@store.effect(when={"keys": [("ui", "add")]})
def persist(event, get_state, emit):
    save_to_disk(get_state()["todos"]["items"])   # your code

emit() returns an EmitHandle. From synchronous code you can block until the effect finishes; from async code you can await it:

python
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:

python
@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

Edit this page on GitHub