Skip to content

Sharing state

Our todo list can display and change its own values. Now let's add a second component that counts the unfinished todos. Checking a box should update both views.

The state management problem

Why is state management hard in web development? The dynamic nature of user interfaces means multiple components must reflect and react to shared and changing data without re-rendering everything anytime something changes (e.g, you recompute the whole app UI whenever a single state variable changes), or convoluted data flows (e.g., state being passed through many layers of components that don’t even use it). Traditional approaches, like Redux, often introduce layers of boilerplate and require careful architecture when planning mutations on the app data.

There is another issue of immutability: we cannot mutate the state directly (e.g., state.todos[0]["done"] = True), since React, and thus Pret, often relies on shallow comparison to detect changes in the state. For instance, if todos is the same object, even though its content has changed, some React utils (e.g. memo) will consider that the state has not changed and will not trigger a re-render.

And if we take care of preventing direct mutations, changing the state can be cumbersome. For instance, if we want to change the done field of the first todo, we would have to do something like this:

new_todos = list(todos)
new_todos[0] = {**todos[0], "done": True}

# We now have todos != new_todos and
# todos[i] == new_todos[i] for all i except 0

Pret's Store

We need one place to keep the data they share. Pret calls it a Store. You can read and modify its lists and dictionaries with familiar Python operations, and components that use those values update automatically.

Create a Store

Start with a few todos and a list of letters. The callback below prints the operations each change saves, with paths relative to the watched value. A Store can also synchronize with the browser, the Python session and a file, we will add those connections after sharing data between components.

from pret.store import create_store, subscribe

store = create_store(
    {
        "todos": [
            {"text": "My first todo", "done": True},
            {"text": "My second todo", "done": False},
        ],
        "letters": ["a", "b"],
    }
)


def on_event(ops, change):
    for op in ops:
        print(op["op"], op["path"], op.get("value"))


subscribe(store, callback=on_event)
store["todos"][1]["done"] = True
set ['todos', 1, 'done'] True
del store["todos"][1]["done"]
delete ['todos', 1, 'done'] None
store["todos"][1]["cool"] = True
set ['todos', 1, 'cool'] True
store["letters"].append("c")
insert ['letters', 2] c
store["letters"][1] = "z"
['letters'] => [{'retain': 1}, {'delete': 1}, {'insert': ['z']}]

Ordinary Store values include dictionaries, lists, strings, numbers, booleans and None. For tables, use Pret DataFrames, introduced in a later tutorial.

Using stores in components

The use_store_snapshot hook reads the Store values used by a component. When those values change, Pret renders that component again. Let's use it to display the todo list.

from pret_joy import Checkbox, Stack

from pret import component, create_store, use_store_snapshot

store = create_store(
    {
        "todos": [
            {"text": "My first todo", "done": True},
            {"text": "My second todo", "done": False},
        ],
    }
)


@component
def TodoList():  # (1)!
    todos = use_store_snapshot(store["todos"])

    def on_change(event, i):
        store["todos"][i]["done"] = event.target.checked

    return Stack(
        [
            Checkbox(
                label=todo["text"],
                checked=todo["done"],
                on_change=lambda event, i=i: on_change(event, i),
            )
            for i, todo in enumerate(todos)
        ],
        spacing=2,
        sx={"m": "1em"},
    )


TodoList()
  1. Both widgets will read the same Store through use_store_snapshot.

Sharing state between components

Display this counter in a second cell, keeping the todo list visible above it. Then check a box in the list, the counter changes too.

from pret_joy import Typography

from pret.react import br


@component
def RemainingTodoCounter():
    todos = use_store_snapshot(store["todos"])
    num_remaining = sum(not todo["done"] for todo in todos)

    return Typography(
        f"Number of unfinished todos: {num_remaining}.",
        br(),
        "Click todos in the previous component to change the count.",
        sx={"m": "1em"},
    )


RemainingTodoCounter()

The Store tracks changes to lists and dictionaries, so you can modify them directly. The snapshot gives React a new value when the data read by the component changes. You do not have to copy every enclosing list or dictionary yourself.

These widgets share data in the browser. To also read their edits from Python, continue with Synchronizing browser and Python values. Pret exchanges ordinary state changes as small JSON operations, eg {"op": "set", "path": ["todos", 0, "done"], "value": true}. Saved Stores keep them in SQLite alongside any DataFrame rows.

Group several changes

The button below checks all todos at once. Without grouping, each assignment sends its own change notification. With transact(store), Pret groups the assignments and notifies subscribers once after the block.

Try both modes and compare the log below the buttons.

from pret_joy import Button, Checkbox, Radio, RadioGroup, Stack, Typography

from pret import (
    component,
    create_store,
    use_effect,
    use_event_callback,
    use_state,
    use_store_snapshot,
)
from pret.store import subscribe, transact

store = create_store(
    [
        {"text": "My first todo", "done": False},
        {"text": "My second todo", "done": False},
        {"text": "My third todo", "done": False},
    ]
)


@component
def TodoList():
    todos = use_store_snapshot(store)
    logs, set_logs = use_state([])
    mode, set_mode = use_state("no-tx")

    is_all_checked = all(t["done"] for t in todos)
    button_all_txt = "Check all" if not is_all_checked else "Uncheck all"

    @use_event_callback
    def on_todo_change(event, i):
        store[i]["done"] = event.target.checked

    @use_event_callback
    def on_click(event):
        if mode == "tx":
            with transact(store):  # <------ this line right here !
                for todo in store:
                    todo["done"] = True if not is_all_checked else False
        else:
            for todo in store:
                todo["done"] = True if not is_all_checked else False

    @use_event_callback
    def on_store_change(ops, change):
        set_logs(lambda logs: [*logs, " | ".join(op["op"] + " " + str(op["path"]) for op in ops)])

    @use_effect(dependencies=[])
    def on_mount():
        return subscribe(store, on_store_change)

    checkboxes = [
        Checkbox(
            label=todo["text"],
            checked=todo["done"],
            on_change=lambda e, i=i: on_todo_change(e, i),
        )
        for i, todo in enumerate(todos)
    ]

    return Stack(
        [
            RadioGroup(
                Radio(value="no-tx", label="Without transact"),
                Radio(value="tx", label="With transact"),
                orientation="horizontal",
                name="mode",
                value=mode,
                on_change=lambda event: set_mode(event.target.value),
                sx={"justifyContent": "center"},
            ),
            Stack(
                Button(button_all_txt, on_click=on_click, sx={"flex": "1"}),
                Button("Reset logs", variant="outlined", on_click=lambda: set_logs([])),
                direction="row",
                spacing=1,
            ),
            *checkboxes,
            Stack([Typography(log, level="body-sm") for log in logs], spacing=1),
        ],
        spacing=2,
        sx={"m": "1em"},
    )


TodoList()