Skip to content

Sharing state

In the previous tutorial, we have seen how to compose a simple component from other components, how to render it, and detect user events. In this tutorial, we will see how to share state between components.

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 system

Pret provides a simple way to manage state in your components. A store, powered by Yjs and py-crdt, can be created and shared between components. Mutations to the state are made easy, and the app automatically knows which components should be re-rendered when a given part of the state changes.

To create a store, we use the create_store wrapper:

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):
    for op in ops:
        print(op.path, "=>", op.keys if hasattr(op, "keys") else op.delta)


subscribe(store, callback=on_event)
store["todos"][1]["done"] = True
['todos', 1] => {'done': {'action': 'update', 'oldValue': False, 'newValue': True}}
del store["todos"][1]["done"]
['todos', 1] => {'done': {'action': 'delete', 'oldValue': True}}
store["todos"][1]["cool"] = True
['todos', 1] => {'cool': {'action': 'add', 'newValue': True}}
store["letters"].append("c")
['letters'] => [{'retain': 2}, {'insert': ['c']}]
store["letters"][1] = "z"
['letters'] => [{'retain': 1}, {'delete': 1}, {'insert': ['z']}]

Supported types

At the moment, not all types can be used in a Pret store. We focus on supporting the most common container types, such as lists and dictionaries, in addition to the basic types (int, float, str, bool, None).

Using stores in components

Now that we have a store object, we can use it in our components. To let Pret know that a component should re-render when a part of the state changes, we use the use_store_snapshot hook, which returns a snapshot of the state. This hook tracks access made on the state, and if a mutation on a part of the state that was accessed is detected :

  • the component will re-render
  • the snapshot will be different from the previous one (meaning, we don't have the new_todos is todos issue mentioned earlier)
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. Note that we don't pass the todos as an argument to the TodoList component anymore. Instead, we use the use_store_snapshot hook to directly subscribe to the global store object.

Sharing state between components

Sharing state between components is now straightforward. Let's display the number of remaining todos in the list. We will use the same store object as the component above.

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

Grouping mutations as transactions

If you have to make a lot of small changes to a shared store in reaction to an event, this will trigger a lot of synchronization routines that can slowdown the app and overcrowd the mutation history.

You can use the transact context manager to group these mutations as one big mutation that will only trigger dependent subscribers (e.g. the server) once.

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(events, tx):
        set_logs(
            lambda logs: [
                *logs,
                "|".join(str(e.path) + ": " + str({k for k in e.keysChanged}) for e in events),
            ]
        )

    @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()