Skip to content

Preserving and Resetting State

Overview

State is isolated between components. ReactPy keeps track of which state belongs to which component based on their place in the UI tree. You can control when to preserve state and when to reset it between re-renders.

You will learn

  • How state is tied to a position in the tree
  • When state is preserved, and when it resets
  • How to reset a component's state on purpose, with position or with key
  • Why defining a component inside another one loses state

State is tied to a position in the tree

When you give a component state, you might think the state "lives" inside the component. It doesn't. ReactPy holds the state, and associates each piece of it with a component by where that component sits in the tree.

Here counter() is rendered at two different positions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@component
def counter():
    score, set_score = use_state(0)

    return html.div(
        {"className": "counter"},
        html.h1(score),
        html.button({"onClick": lambda event: set_score(score + 1)}, "Add one"),
    )


@component
def app():
    # The same component function, rendered at two different positions in the
    # tree. ReactPy tracks state by position, so these two counters each get
    # their own score.
    return html.div(counter(), counter())
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
.counter {
    width: 130px;
    text-align: center;
    border: 1px solid #aaa;
    border-radius: 4px;
    padding: 12px;
    margin: 0 12px 12px 0;
}

.counter h1 {
    font-size: 18px;
    margin: 0 0 6px;
}

.fancy {
    background: #ffffd8;
    border-color: #e0b400;
}
1
# TODO

Click both counters and notice they don't affect each other. Each position gets its own state, so counter() is really two separate components here.

It makes no difference whether you build the child once and reuse it, or call it twice:

one_counter = counter()
html.div(one_counter, one_counter)  # same as: html.div(counter(), counter())

Both render counter() at two positions, and both get independent state.

Same component at the same position preserves state

In this example a checkbox flips a prop on the counter, but there is always exactly one counter() at that position:

 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 counter(is_fancy):
    score, set_score = use_state(0)

    return html.div(
        {"className": f"counter{' fancy' if is_fancy else ''}"},
        html.h1(score),
        html.button({"onClick": lambda event: set_score(score + 1)}, "Add one"),
    )


@component
def app():
    is_fancy, set_is_fancy = use_state(False)

    return html.div(
        # Only one counter() is ever rendered at this position, and it is the
        # same component function either way, so ReactPy keeps its state across
        # the toggle; only the is_fancy prop changes.
        counter(is_fancy),
        html.label(
            html.input(
                {
                    "type": "checkbox",
                    "checked": is_fancy,
                    "onChange": lambda event: set_is_fancy(event["target"]["checked"]),
                }
            ),
            "Use fancy styling",
        ),
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
.counter {
    width: 130px;
    text-align: center;
    border: 1px solid #aaa;
    border-radius: 4px;
    padding: 12px;
    margin: 0 12px 12px 0;
}

.counter h1 {
    font-size: 18px;
    margin: 0 0 6px;
}

.fancy {
    background: #ffffd8;
    border-color: #e0b400;
}
1
# TODO

Raise the score, then toggle "Use fancy styling". The score survives, because the same component stayed at the same position — only its is_fancy prop changed.

This is the default, and usually what you want.

Different components at the same position reset state

Now switch the child to something else entirely:

 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 counter():
    score, set_score = use_state(0)

    return html.div(
        {"className": "counter"},
        html.h1(score),
        html.button({"onClick": lambda event: set_score(score + 1)}, "Add one"),
    )


@component
def app():
    is_paused, set_is_paused = use_state(False)

    return html.div(
        # Switching between a counter() and an html.p() puts a *different* kind
        # of child at this position, so ReactPy discards the counter's state
        # and starts a brand new one when the break ends.
        html.p("See you later!") if is_paused else counter(),
        html.label(
            html.input(
                {
                    "type": "checkbox",
                    "checked": is_paused,
                    "onChange": lambda event: set_is_paused(event["target"]["checked"]),
                }
            ),
            "Take a break",
        ),
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
.counter {
    width: 130px;
    text-align: center;
    border: 1px solid #aaa;
    border-radius: 4px;
    padding: 12px;
    margin: 0 12px 12px 0;
}

.counter h1 {
    font-size: 18px;
    margin: 0 0 6px;
}

.fancy {
    background: #ffffd8;
    border-color: #e0b400;
}
1
# TODO

Raise the score, tick "Take a break", then untick it. The counter comes back at zero. Replacing the child with an html.p() destroyed the old state, and the returning counter() is a brand new one.

The rule so far: same component at the same position keeps state; anything else at that position does not.

Resetting state at the same position

Suppose a scoreboard shows one player's score at a time, and "Next player" swaps Taylor for Sarah:

counter("Taylor") if is_player_a else counter("Sarah")

Both branches call counter() at the same position, so ReactPy sees one component whose person prop changed — and the score follows the player over. That's wrong here: Taylor's score and Sarah's score are different things.

There are two ways to get them treated as separate components.

Option 1: render them at different positions

Give each player a container that always exists:

 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
@component
def counter(person):
    score, set_score = use_state(0)

    return html.div(
        {"className": "counter"},
        html.h1(f"{person}'s score: {score}"),
        html.button({"onClick": lambda event: set_score(score + 1)}, "Add one"),
    )


@component
def scoreboard():
    is_player_a, set_is_player_a = use_state(True)

    return html.div(
        # Two containers that always exist, so each player's counter has its
        # own position in the tree. Only one holds a counter at a time, and the
        # container that loses it destroys that state.
        #
        # Do not "simplify" this to *([] if cond else [counter(...)]): an empty
        # expansion contributes no children, so both counters end up at
        # position 0 and ReactPy treats them as one component -- no reset.
        html.div(counter("Taylor") if is_player_a else None),
        html.div(counter("Sarah") if not is_player_a else None),
        html.button(
            {"onClick": lambda event: set_is_player_a(not is_player_a)},
            "Next player!",
        ),
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
.counter {
    width: 130px;
    text-align: center;
    border: 1px solid #aaa;
    border-radius: 4px;
    padding: 12px;
    margin: 0 12px 12px 0;
}

.counter h1 {
    font-size: 18px;
    margin: 0 0 6px;
}

.fancy {
    background: #ffffd8;
    border-color: #e0b400;
}
1
# TODO
  • At first the first html.div() holds Taylor's counter and the second is empty.
  • After "Next player!" the first is empty and the second holds Sarah's.

Each counter is destroyed when its container empties, so every switch starts from zero. This works nicely when there are only a couple of alternatives to lay out side by side.

An empty list expansion is not a placeholder

The tempting Python spelling of "or nothing" collapses the whole mechanism:

*([] if not is_player_a else [counter("Taylor")]),  # wrong: no child at all

An empty expansion contributes no children, so the two counters land at position 0 and ReactPy treats them as one component — no reset, and Sarah inherits Taylor's score. It is the two html.div() containers above that create the positions. (React avoids this because false still occupies a JSX slot.)

Option 2: reset state with a key

You have seen "key" when rendering lists. Keys are not only for lists. By default ReactPy identifies a child by its index within its parent — "the first counter", "the second counter" — and a key lets you say this is not the first counter, it is Taylor's counter.

 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
@component
def counter(person):
    score, set_score = use_state(0)

    return html.div(
        {"className": "counter"},
        html.h1(f"{person}'s score: {score}"),
        html.button({"onClick": lambda event: set_score(score + 1)}, "Add one"),
    )


@component
def scoreboard():
    is_player_a, set_is_player_a = use_state(True)

    return html.div(
        # Both branches render counter() at the same position, so without a key
        # ReactPy would treat them as the same component and carry the score
        # from one player to the next. The key gives each player their own
        # identity, so switching players resets the score.
        counter("Taylor", key="Taylor")
        if is_player_a
        else counter("Sarah", key="Sarah"),
        html.button(
            {"onClick": lambda event: set_is_player_a(not is_player_a)},
            "Next player!",
        ),
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
.counter {
    width: 130px;
    text-align: center;
    border: 1px solid #aaa;
    border-radius: 4px;
    padding: 12px;
    margin: 0 12px 12px 0;
}

.counter h1 {
    font-size: 18px;
    margin: 0 0 6px;
}

.fancy {
    background: #ffffd8;
    border-color: #e0b400;
}
1
# TODO

The two counters no longer share state, even though they appear in the same place. Give "key" a value that identifies what the component is — the contact id, the player name — not where it sits.

Keys are only unique within their parent

Two components with the same key under different parents are fine: keys are not globally unique, they only identify a position within one parent.

Note also that an unkeyed child is identified by its index, which shares a namespace with explicit keys. html.div(contact_list(...), chat(to, key=0)) raises ValueError: Duplicate keys [0], because the unkeyed contact_list() already occupies position 0. Prefix your keys (key=f"chat-{to['id']}") or key all the siblings.

Resetting a form with a key

Keying is most useful for forms. In this chat app, chat() holds the half-written message:

 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
41
42
@component
def chat(contact):
    draft, set_draft = use_state("")

    return html.section(
        {"className": "chat"},
        html.h3(f"To: {contact['name']} <{contact['email']}>"),
        html.textarea({"placeholder": "Write a message", "value": draft}),
        html.button({"onClick": lambda event: set_draft("")}, "Clear"),
    )


@component
def contact_list(selected_contact, on_select):
    return html.section(
        {"className": "contact-list"},
        html.ul(
            [
                html.li(
                    {"key": contact["id"]},
                    html.button(
                        {"onClick": lambda event, c=contact: on_select(c)},
                        contact["name"],
                    ),
                )
                for contact in contacts
            ]
        ),
    )


@component
def messenger():
    to, set_to = use_state(contacts[0])

    return html.div(
        contact_list(to, set_to),
        # Without the key, switching contacts keeps the half-written message,
        # because chat() stays at the same position. The key ties the draft to
        # the contact instead of the slot.
        chat(to, key=f"chat-{to['id']}"),
    )
1
2
3
4
5
contacts = [
    {"id": 0, "name": "Taylor", "email": "taylor@mail.com"},
    {"id": 1, "name": "Alice", "email": "alice@mail.com"},
    {"id": 2, "name": "Bob", "email": "bob@mail.com"},
]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
.contact-list ul {
    list-style: none;
    padding: 0;
    display: flex;
    gap: 8px;
}

.chat {
    border: 1px solid #aaa;
    border-radius: 4px;
    padding: 10px;
    margin-top: 10px;
}

.chat textarea {
    display: block;
    width: 100%;
    min-height: 60px;
}
1
# TODO

Type a draft, then pick another contact. Without the key the draft would follow you over, because chat() never left its position. With key=f"chat-{to['id']}" the draft belongs to the conversation rather than the slot.

Keeping state for a component that is no longer rendered

Resetting is often not what you want — a real chat app should restore the draft when you go back to Alice. Keying does not do that: an unmounted component loses its state, so returning to Taylor starts at zero. Options:

  • Render all the chats and hide the inactive ones with CSS. Nothing is unmounted, so state survives. Great for small UIs, wasteful when the hidden trees are large.
  • Lift the state up and keep each recipient's draft in the parent, in a dict keyed by recipient. Children coming and going stops mattering. This is the most common answer.
  • Keep it outside ReactPy entirely — a database, or localStorage in the browser — which you want anyway if a draft should survive a page reload.

Whichever you choose, a chat with Alice is conceptually different from a chat with Bob, so it still deserves a distinct key.

Nested component definitions reset state accidentally

This one bites quietly:

@component
def outer():
    @component
    def nested():
        value, set_value = use_state(...)
        ...

    return html.div(nested())

Every render of outer() creates a new function object for nested(). A new function is a new component, so ReactPy discards the old state and mounts a fresh one on every render — your state resets itself, with no warning. Measured: a nested child's mount counter climbs by one on each parent render, while a module-level child mounted once.

Define components at module level, or pass data through props.

Recap

  • ReactPy keeps state for as long as the same component is rendered at the same position.
  • State is not stored in the call that produced the VDOM; it belongs to the tree position you put it in.
  • Force a subtree to reset by rendering it at a different position, or by giving it a different "key".
  • Unkeyed siblings are identified by index, in the same key namespace as explicit keys — prefix your keys to avoid ValueError: Duplicate keys.
  • Don't define a component inside another component, or you'll reset state by accident.
Authors: Archmonger