Skip to content

State as a Snapshot

Overview

State variables might look like regular Python variables that you can read and write to. However, state behaves more like a snapshot. Setting it does not change the state variable you already have, but instead triggers a re-render.

You will learn

  • How setting state triggers renders
  • Why variable and prop values are fixed when a render is started
  • Why event handlers see old values of state

Setting state triggers renders

You might think of your user interface as changing directly in response to the user event like a click. In ReactPy, it works a little differently from this mental model. You have already seen that setting state requests a re-render. This means that for an interface to react to an event, you need to update the state.

In this example, when you press "send", set_is_sent(True) tells ReactPy to re-render the UI:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@component
def form():
    is_sent, set_is_sent = use_state(False)
    message, set_message = use_state("Hi!")

    if is_sent:
        return html.h1("Your message is on its way!")

    async def handle_submit(event):
        event.preventDefault()
        set_is_sent(True)
        send_message(message)

    async def handle_message_change(event):
        set_message(await event.target.value)

    return html.form(
        {"onSubmit": handle_submit},
        html.textarea(
            {
                "placeholder": "Message",
                "value": message,
                "onChange": handle_message_change,
            }
        ),
        html.button({"type": "submit"}, "Send"),
    )


def send_message(message):
    print(f"Sending: {message}")
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
button {
    display: inline-block;
    margin: 10px;
    font-size: 20px;
}

h1 {
    display: inline-block;
    margin: 10px;
    width: 30px;
    text-align: center;
}

label,
textarea {
    display: block;
    margin-bottom: 10px;
}
1
# TODO

Here's what happens when you click the button:

  1. The onSubmit event handler executes.
  2. set_is_sent(True) sets is_sent to True and queues a new render.
  3. ReactPy re-renders the component according to the new is_sent value.

Let's take a closer look at the relationship between state and rendering.

Rendering takes a snapshot in time

"Rendering" means that ReactPy is calling your component, which is a function. The VDOM you return from that function is like a snapshot of the UI in time. Its props, event handlers, and local variables were all calculated using its state at the time of the render.

Unlike a photograph or a movie frame, the UI "snapshot" you return is interactive. It includes logic like event handlers that specify what happens in response to inputs. ReactPy updates the screen to match this snapshot and connects the event handlers. As a result, pressing a button triggers the click handler from that snapshot.

When ReactPy re-renders a component:

  1. ReactPy calls your function again.
  2. Your function returns a new VDOM snapshot.
  3. ReactPy then updates the screen to match the snapshot you've returned.

As a component's memory, state is not like a regular variable that disappears after your function returns. State actually "lives" in ReactPy itself — as if on a shelf! — outside of your function. When ReactPy calls your component, it gives you a snapshot of the state for that particular render. Your component returns a snapshot of the UI with a fresh set of props and event handlers, all calculated using the state values from that render!

Here's a little experiment to show you how this works. Clicking the "+3" button calls set_number(number + 1) three times, so you might expect three increments.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@component
def counter():
    number, set_number = use_state(0)

    async def handle_click(event):
        set_number(number + 1)
        set_number(number + 1)
        set_number(number + 1)

    return html.div(
        html.h1(number),
        html.button({"onClick": handle_click}, "+3"),
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
button {
    display: inline-block;
    margin: 10px;
    font-size: 20px;
}

h1 {
    display: inline-block;
    margin: 10px;
    width: 30px;
    text-align: center;
}

label,
textarea {
    display: block;
    margin-bottom: 10px;
}
1
# TODO

Notice that number only increments once per click — verified by dispatching a click and reading the result.

Setting state only changes it for the next render. During the first render, number was 0. This is why, in that render's handle_click, the value of number is still 0 even after set_number(number + 1) was called:

Here is what this button's click handler tells ReactPy to do:

  1. set_number(number + 1): number is 0, so set_number(0 + 1).
    • ReactPy prepares to change number to 1 on the next render.
  2. set_number(number + 1): number is 0, so set_number(0 + 1).
    • ReactPy prepares to change number to 1 on the next render.
  3. set_number(number + 1): number is 0, so set_number(0 + 1).
    • ReactPy prepares to change number to 1 on the next render.

Even though you called set_number(number + 1) three times, in this render's event handler number is always 0, so you set the state to 1 three times. This is why, after your event handler finishes, ReactPy re-renders the component with number equal to 1 rather than 3.

You can also visualize this by mentally substituting state variables with their values in your code. Since number is 0 for this render, its event handler effectively reads:

async def handle_click(event):
    set_number(0 + 1)
    set_number(0 + 1)
    set_number(0 + 1)

For the next render number is 1, so that render's click handler reads set_number(1 + 1) three times. This is why clicking again takes the counter to 2, then 3, and so on.

This is Python's own closure rule, not a ReactPy feature

There is no magic here. handle_click is a closure over the local name number created during one specific call to counter(). That name is bound to the value ReactPy handed to that call, and no later assignment elsewhere can change what it refers to. ReactPy's contribution is simply that each render is a fresh call, so each render gets a fresh handle_click closing over fresh values.

It is worth stating plainly, because the usual Python instinct is wrong here: set_number does not write to number. It writes into ReactPy's hook state, on the shelf outside your function.

State over time

Try to guess what this button's second column will show.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@component
def counter():
    number, set_number = use_state(0)
    seen, set_seen = use_state([])

    async def handle_click(event):
        set_number(number + 5)
        # `number` here is still the value from THIS render.
        set_seen([*seen, number])

    return html.div(
        html.h1(number),
        html.button({"onClick": handle_click}, "+5"),
        html.p("Handler saw: ", ", ".join(map(str, seen)) or "-"),
    )
1
# TODO

Using the substitution method, number is 0, so the first click records 0 — even though set_number(number + 5) ran one line earlier. Click again and it records 5, because in that render number is 5.

But what if you delay that read, so it only happens after the component re-renders? Would it say 0 or 5? Have a guess!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@component
def counter():
    number, set_number = use_state(0)
    seen, set_seen = use_state([])

    async def handle_click(event):
        set_number(number + 5)

        def delayed():
            # Runs long after this render, but still sees this render's value.
            set_seen([*seen, number])

        asyncio.get_running_loop().call_later(3, delayed)

    return html.div(
        html.h1(number),
        html.button({"onClick": handle_click}, "+5"),
        html.p("Handler saw (3s later): ", ", ".join(map(str, seen)) or "-"),
    )
1
# TODO

Surprised? If you use the substitution method, you can see the "snapshot" of the state that was captured.

set_number(0 + 5)
get_running_loop().call_later(3, delayed)   # delayed() closes over number == 0

The state stored in ReactPy may have changed by the time the callback runs, but the callback was scheduled using a snapshot of the state at the time the user interacted with it.

The updater form rescues only the value it updates

The scheduled callback above works because set_seen is stable and the closure captures a value, not a live binding. The trap is the reverse: a callback that reaches for the current value gets the one from whichever render created it. An updater looks like the fix, but it is narrower than it appears. ReactPy applies an updater to the current value of that one state variable the instant you call the setter (core/hooks.py, _CurrentState.dispatch calls new(self.value)), so set_seen(lambda previous: [*previous, number]) hands you the freshest seen — while number inside it is still the name frozen when the callback was built. Reach for the updater when the value you need is the value you are setting. For anything else, read it before scheduling and pass it in, or keep it in a use_ref box and read box.current when the callback finally runs.

A state variable's value never changes within a render, even if its event handler's code is asynchronous. Inside that render's handle_click, the value of number continues to be 0 even after set_number(number + 5) was called. Its value was "fixed" when ReactPy "took the snapshot" of the UI by calling your component.

Here is an example of how that makes your event handlers less prone to timing mistakes. Below is a form that sends a message with a five-second delay. Imagine this scenario:

  1. You press the "Send" button, sending "Hello" to Alice.
  2. Before the five-second delay ends, you change the value of the "To" field to "Bob".

What do you expect the status line to display? Would it display, "You said Hello to Alice"? Or would it display, "You said Hello to Bob"? Make a guess based on what you know, and then try it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@component
def form():
    to, set_to = use_state("Alice")
    message, set_message = use_state("Hello")
    status, set_status = use_state("Not sent yet")

    async def handle_submit(event):
        # This handler closes over `to` and `message` from ITS OWN render.
        set_status("Sending...")
        await asyncio.sleep(5)
        set_status(f"You said {message} to {to}")

    async def handle_to_change(event):
        set_to(await event.target.value)

    async def handle_message_change(event):
        set_message(await event.target.value)

    return html.div(
        html.label(
            "To: ",
            html.input(
                {
                    "value": to,
                    "onChange": handle_to_change,
                }
            ),
        ),
        html.label(
            "Message: ",
            html.input(
                {
                    "value": message,
                    "onChange": handle_message_change,
                }
            ),
        ),
        html.button({"onClick": handle_submit}, "Send"),
        html.p(status),
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
button {
    display: inline-block;
    margin: 10px;
    font-size: 20px;
}

h1 {
    display: inline-block;
    margin: 10px;
    width: 30px;
    text-align: center;
}

label,
textarea {
    display: block;
    margin-bottom: 10px;
}
1
# TODO

If you press Send and then change "To" before the five seconds elapse, you still get "You said Hello to Alice". Verified: pressing Send and changing the recipient to "Bob" mid-flight produced exactly that string.

That is the payoff of snapshots. handle_submit was created by the render in which to was "Alice", so it keeps saying "Alice" no matter what later renders decide. Had the handler read the state through some live reference instead, changing the field mid-request would have sent the message to Alice while reporting that it went to Bob.

React's own version of this demo uses setTimeout and a browser alert. ReactPy has no alert on the server, so the equivalent here is a status paragraph — and asyncio.sleep rather than a timer callback. If you want the browser to alert, that is the inlineJavaScript escape hatch, not a Python function call.

Recap

  • Setting state does not change the state variable you already have; it requests a render.
  • Every render captures its own snapshot of state, props, and handlers. Values are fixed for the duration of that render.
  • This follows Python's ordinary closure rules: each render is a fresh call, so each handler closes over fresh values.
  • Reading state inside a delayed callback or an await gives you the value from the render that created that callback, which is usually what you want.
  • When you genuinely need the latest value inside a callback, use the updater form set_x(lambda previous: ...).
Authors: Archmonger