Skip to content

Updating Arrays in State

Overview

Lists are mutable in Python, but you should treat them as immutable when you store them in state. Just like with objects, when you want to update a list stored in state, you need to create a new one (or make a copy of an existing one), and then set state to use the new list.

You will learn

  • How to add, remove, or change items in a list in ReactPy state
  • How to update an object inside of a list
  • Why a mutating update is dropped without any error

Updating lists without mutation

In JavaScript you copy arrays with the spread syntax, and mutating methods like push() and pop() are easy to reach for by mistake. Python's equivalent methods — append(), extend(), insert(), remove(), pop(), sort(), reverse() — are in-place and return None, so the mistake looks different and fails harder:

def handle_add(event):
    items.append({"id": next_id, "text": text})   # mutates, returns None
    set_items(items)                              # same object -> ignored

Nothing happens. ReactPy compares the new value against the old one with == (core/hooks.py, strictly_equal), sees nothing changed, and schedules no render. There is no error, no warning, and nothing in the browser console. Verified directly: mutating then setting the same list produces no render at all. Nor does setting a new list that happens to compare equal — which is why set_rows([*rows, 4]) works while mutating rows and passing it back does not.

So whenever you want to update a list, pass a new list to the setter. Python gives you two tools for that:

Instead of (mutates) Use (new list)
items.append(x) [*items, x]
items.remove(x) [i for i in items if i is not x]
items[i] = x [*items[:i], x, *items[i + 1:]]
items.pop(i) [*items[:i], *items[i + 1:]]
items.insert(i, x) [*items[:i], x, *items[i:]]
items.reverse() list(reversed(items))
items.sort(key=f) sorted(items, key=f)

Never store an iterator in state

This one has no JavaScript analogue and will bite you. Python's filter() and map() return lazy, single-use iterators, not lists:

set_items(filter(lambda i: i["id"] != id_to_delete, items))   # 🚩

That renders correctly once, which is exactly what makes it so hard to diagnose. The iterator is consumed while ReactPy walks the children, so the next render of that same state sees an exhausted iterator and displays nothing. Verified: a list of three items filtered to "a,c" on the first render, then an unrelated state change rendered an empty line.

Always build a list — a comprehension is clearest, list(filter(...)) if you must:

set_items([i for i in items if i["id"] != id_to_delete])   # correct

Adding to a list

The common case is adding an item. The handler creates a new list ending with the new item:

 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 todo_list():
    items, set_items = use_state([{"id": 0, "text": "Walk the dog"}])
    text, set_text = use_state("")

    def handle_add(event):
        event.preventDefault()
        if not text:
            return
        # A new list; `items.append(...)` would be silently ignored.
        next_id = max((item["id"] for item in items), default=-1) + 1
        set_items([*items, {"id": next_id, "text": text}])
        set_text("")

    return html.div(
        html.form(
            {"onSubmit": handle_add},
            html.input(
                {
                    "value": text,
                    "placeholder": "To do...",
                    "onChange": lambda event: set_text(event["target"]["value"]),
                }
            ),
            html.button({"type": "submit"}, "Add"),
        ),
        html.ul([html.li({"key": item["id"]}, item["text"]) for item in items]),
    )
1
# TODO

Type a task and submit; it appends while the existing ones stay put. Note that the new id is computed from the existing items rather than from a module-level counter. A global counter is shared by every visitor to the server, so two users would collide on the same key — derive ids from the state you already have, or use uuid4().

Removing from a list

The most common removal is filtering out one item:

 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
@component
def todo_list():
    items, set_items = use_state(
        [
            {"id": 0, "text": "Walk the dog"},
            {"id": 1, "text": "Brush the cat"},
            {"id": 2, "text": "Teach the fish to juggle"},
        ]
    )

    def handle_delete(id_to_keep):
        def handler(event):
            # filter() gives a new list; items.remove(...) would mutate it.
            set_items([item for item in items if item["id"] != id_to_keep])

        return handler

    return html.ul(
        [
            html.li(
                {"key": item["id"]},
                item["text"],
                html.button({"onClick": handle_delete(item["id"])}, "Delete"),
            )
            for item in items
        ]
    )
1
# TODO

[item for item in items if item["id"] != id_to_keep] produces a brand-new list containing everything except the deleted item, and the original is left alone.

Transforming every item

To apply a change to every item, build a new list by transforming each element:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@component
def todo_list():
    items, set_items = use_state(
        [
            {"id": 0, "text": "Walk the dog"},
            {"id": 1, "text": "Brush the cat"},
        ]
    )

    def handle_click(event):
        # map() becomes a comprehension; the old items stay untouched.
        set_items([{"id": item["id"], "text": item["text"] + "!"} for item in items])

    return html.div(
        html.button({"onClick": handle_click}, "Add !"),
        html.ul([html.li({"key": item["id"]}, item["text"]) for item in items]),
    )
1
# TODO

Each item becomes a new dict, because mutating item["text"] in place would leave the previous state pointing at the same mutated object.

Comprehensions do not modify the original

React's docs point out that filter() doesn't touch the original array, and a Python comprehension has the same property — it reads the old list and returns a fresh one. The catch, stated above, is that the built-in filter() does not return a list at all.

Replacing an item

Unlike most languages, Python has no setAt(i, x) method, and the obvious items[i] = x mutates. Build the list from slices instead:

 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
@component
def todo_list():
    items, set_items = use_state(
        [
            {"id": 0, "text": "Walk the dog"},
            {"id": 1, "text": "Brush the cat"},
        ]
    )
    text, set_text = use_state("")

    def handle_replace(event):
        event.preventDefault()
        if not text:
            return
        # Replace one item by building a whole new list. Mutating
        # items[0]["text"] would be invisible to ReactPy.
        set_items([{"id": 0, "text": text}, *items[1:]])
        set_text("")

    return html.div(
        html.form(
            {"onSubmit": handle_replace},
            html.input(
                {
                    "value": text,
                    "placeholder": "Replace the first item",
                    "onChange": lambda event: set_text(event["target"]["value"]),
                }
            ),
            html.button({"type": "submit"}, "Replace"),
        ),
        html.ul([html.li({"key": item["id"]}, item["text"]) for item in items]),
    )
1
# TODO

[{"id": 0, "text": text}, *items[1:]] replaces the first item and copies the rest. For an arbitrary index, [*items[:i], new_item, *items[i + 1:]] is the general form.

Inserting into a list

To insert an item somewhere other than the end, slice around the insertion point:

 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
@component
def todo_list():
    items, set_items = use_state(
        [
            {"id": 0, "text": "Walk the dog"},
            {"id": 1, "text": "Brush the cat"},
        ]
    )
    text, set_text = use_state("")

    def handle_insert(event):
        event.preventDefault()
        if not text:
            return
        # Slicing builds the new list; list.insert() would mutate in place.
        set_items([*items[:1], {"id": 3, "text": text}, *items[1:]])
        set_text("")

    return html.div(
        html.form(
            {"onSubmit": handle_insert},
            html.input(
                {
                    "value": text,
                    "placeholder": "Insert at index 1",
                    "onChange": lambda event: set_text(event["target"]["value"]),
                }
            ),
            html.button({"type": "submit"}, "Insert"),
        ),
        html.ul([html.li({"key": item["id"]}, item["text"]) for item in items]),
    )
1
# TODO

[*items[:1], new_item, *items[1:]] puts the new item at index 1. This is how React's "insert into an array" example translates; list.insert() mutates and is unusable here.

Making other changes to a list

The rule is uniform: copy first, then operate on the copy, or use a function that already returns a new list.

 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
@component
def todo_list():
    items, set_items = use_state(
        [
            {"id": 0, "text": "Walk the dog"},
            {"id": 1, "text": "Brush the cat"},
            {"id": 2, "text": "Feed the fish"},
        ]
    )
    log, set_log = use_state([])

    def record(label, next_items):
        set_items(next_items)
        set_log([*log, f"{label}: {', '.join(i['text'] for i in next_items)}"])

    return html.div(
        html.div(
            html.button(
                {"onClick": lambda event: record("reversed", list(reversed(items)))},
                "Reverse",
            ),
            html.button(
                {
                    "onClick": lambda event: record(
                        "sorted", sorted(items, key=lambda i: i["text"])
                    )
                },
                "Sort",
            ),
            html.button(
                {"onClick": lambda event: record("filtered", items[:2])},
                "Keep first two",
            ),
        ),
        html.ul([html.li({"key": item["id"]}, item["text"]) for item in items]),
        html.ul([html.li({"key": i}, line) for i, line in enumerate(log)]),
    )
1
# TODO

reversed() returns an iterator, so wrap it in list(...); sorted() already returns a new list; items[:2] is a slice, which is already a new list.

list.sort() and list.reverse() return None

These two are the most expensive traps because the failure is invisible. items.sort() sorts in place and returns None, so set_items(items.sort()) stores None in state. Use sorted(items) and list(reversed(items)).

Updating objects inside lists

The examples above replaced whole dicts, which is the safe default. When an item is a dict whose fields you want to change one at a time, you need two copies: a new list, and a new dict for the item that changed.

 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
@component
def form():
    artworks, set_artworks = use_state(initial_artworks())

    def handle_toggle(id_to_toggle):
        def handler(event):
            # Copy the LIST, and copy the one dict inside it that changed.
            # A shallow copy would leave the dict shared with the old state.
            set_artworks(
                [
                    {**artwork, "seen": not artwork["seen"]}
                    if artwork["id"] == id_to_toggle
                    else artwork
                    for artwork in artworks
                ]
            )

        return handler

    return html.ul(
        [
            html.li(
                {"key": artwork["id"]},
                html.label(
                    html.input(
                        {
                            "type": "checkbox",
                            "checked": artwork["seen"],
                            "onChange": handle_toggle(artwork["id"]),
                        }
                    ),
                    f"{artwork['title']} - {artwork['artist']}",
                ),
            )
            for artwork in artworks
        ]
    )
1
# TODO

Tick one checkbox and only that artwork's "seen" flips — verified, the pair goes from [False, False] to [True, False]. The comprehension copies every item, and only the targeted one is rebuilt with the changed field.

A shallow copy of the list is not enough

It is tempting to write:

next_items = list(artworks)          # new list...
next_items[0]["seen"] = True         # ...but the SAME dicts inside
set_items(next_items)

That is a new list containing the same dict objects. Two problems follow. The assignment mutates a dict the previous state still references, so the change is invisible to ReactPy's comparison. And anything else holding that dict — another component seeded from the same fixture, a cached render — sees it flip too.

Copying is shallow at every level in Python; there is no deep copy on assignment or unpacking. Build the changed dict explicitly, as the example does. See Updating a nested object for the object-side version of this.

Keep identity stable for unchanged items

Notice the else artwork branch: unchanged items are passed through as the same objects, not rebuilt. That is both cheaper and useful — if you rebuild everything on each keystroke, nothing can be recognised as unchanged later.

Writing concise update logic

React's docs finish this page with Immer, which lets you write draft.push(...) behind a proxy. ReactPy has no Immer and no equivalent. There is no proxy layer to record your mutations, and no use_immer to import.

What you have instead is what this page already used:

  • List unpacking and comprehensions — [*items, x], [f(i) for i in items], [*items[:i], x, *items[i:]]. Verbose but explicit, and it fails loudly rather than silently.
  • copy.deepcopy — for a structure too nested to copy by hand.
  • use_reducer — moves the update logic out of the component, which is where the verbosity actually starts to hurt. See Extracting State Logic into a Reducer.

A homegrown Immer is worth avoiding specifically because it would paper over the failure mode this page is about: mutations are more dangerous here than in the browser, because ReactPy decides whether to render by comparing the old and new values on the server — so a mutation that hands back the same object, or an equal one, is dropped without a trace.

Recap

  • Treat lists in state as read-only: never call append(), sort(), insert(), remove(), and friends on them.
  • Build a new list with [*items, x] or a comprehension, and pass that to the setter.
  • Mutating and passing the same list back is silently dropped — no render, no error.
  • Never store filter()/map() results in state; they are single-use iterators and the second render will be empty.
  • To change an object inside a list, copy the list and copy the changed dict.
  • There is no Immer in ReactPy; use comprehensions, deepcopy, or use_reducer.
Authors: Archmonger