Choosing the State Structure
Overview¶
Structuring state well is the difference between a component that is pleasant to modify and one that is a constant source of bugs. ReactPy makes these guidelines matter more than they do in React, because it detects state changes with == rather than by identity, and because it re-renders every child of a component that re-renders. Each principle below comes with the failure it prevents, measured.
You will learn
- When to group state into one object, and what that trade actually costs
- Why contradictions, redundancy, and duplication each buy you a bug
- How ReactPy's
==comparison changes the "never mutate state" rule - What a flat state shape does to the cost of an update
Principles for structuring state¶
When you write a component that holds state, you choose how many state variables to use and what shape their data has. A poor shape still runs correctly; it just makes mistakes easier to make. Five principles point the other way:
- Group related state. If you always update two or more state variables together, merge them into one.
- Avoid contradictions in state. If two pieces of state can disagree, you have left room for a bug.
- Avoid redundant state. If you can calculate a value from props or existing state during render, do not put it in state.
- Avoid duplication in state. Data stored in two places must be kept in sync in two places.
- Avoid deeply nested state. To change one leaf you must rebuild every ancestor.
The goal is to make state easy to update without introducing mistakes — the same reason a database engineer normalizes a schema. To paraphrase Einstein: make your state as simple as it can be, but no simpler.
One fact about ReactPy changes how all five pay off, so it comes first.
ReactPy compares state with ==¶
use_state compares the value you set against the current one with strictly_equal, which falls through to ==. If the two compare equal, nothing re-renders. Verified against a real Layout, one press each:
| You set | over | Renders |
|---|---|---|
{"items": [1, 2]} — a brand-new dict | {"items": [1, 2]} | 0 |
[1, 2, 3] — a brand-new list | [1, 2, 3] | 0 |
[] | [] | 0 |
{"a": {"b": 2}} | {"a": {"b": 1}} | 1 |
[1, 2] | (1, 2) | 1 |
The first three rows are the ones to internalise. In React, setting a fresh object always re-renders, because React compares by identity. In ReactPy, a new object that reads the same as the old one is discarded.
That makes the "never mutate state" rule stricter, not looser:
rows["items"].append("x")
set_rows(rows) # same object, and == equal -> no render at all
Verified: three presses of that handler leave the rendered count at n=0 with the component body having run once. The mutation did happen — the list really grew — but nothing re-rendered to show it, so the screen and the data now disagree. Build a new object instead ({**rows, "items": [*rows["items"], "x"]}) and the update becomes visible.
It also means a tuple and a list are different states: [1, 2] over (1, 2) renders, because strictly_equal checks type(x) is type(y) before anything else. Pick one container type per field. See Lifecycle of Reactive Effects for the full comparison rules.
Group related state¶
Should you use several state variables or one object?
Technically either works. But if two state variables always change together, put them in one state variable, so there is no second setter call to forget. Here the two coordinates are separate, and one handler forgets the second call:
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 43 44 | |
1 | |
Press Move (forgot y): the dot travels horizontally and the readout confirms the split — x=44 y=20. Every later move compounds it, because each handler builds on the state it already has.
With one object, the mistake has nowhere to live:
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 | |
1 | |
Two presses of Move diagonally give x=68 y=68. There is no second setter to omit: one set_position call carries both axes, and the dict you build has to say something about each one.
The trade is worth naming, because it is not free. A single object means every update rebuilds the whole object, and position["x"] is noisier than x. ReactPy does not sweeten it with fewer renders: verified, two presses of "move both" cost two renders in the separate version and two in the grouped version, and moving only x costs one render in both. Group related state to remove a class of bug, not to save work.
Avoid contradictions in state¶
A feedback form tracks its send with two booleans:
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 43 44 45 46 47 48 49 | |
1 | |
The code works — and it permits a state that should be impossible. Press Send (buggy), which sets is_sent without clearing is_sending, and the readout reports:
is_sending=True is_sent=True
That is not hypothetical. Nothing in the data structure prevents those two flags being true together, so the only thing protecting you is remembering every setter in every handler. The readout exists so you can see the disagreement; in a real component you would instead see a spinner that never stops.
Since the flags can never both be true, replace them with one variable holding one of three values:
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 | |
1 | |
status passes through "sending" and ends at "sent", which swaps the form for a thank-you. There is no way to be in two states at once, because there is only one variable.
You can still write the readable names:
is_sending = status == "sending"
is_sent = status == "sent"
Those are plain local variables computed during render, not state. They cannot drift, which is exactly why they should not be state.
Avoid redundant state¶
A check-in form shows a full name built from two fields:
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 43 44 45 | |
1 | |
It has three state variables, and one is redundant — full_name is derivable from the other two at any moment. That is not merely wasteful; it is a second place every writer must remember. Press Rename first: first_name becomes "Grace", and the ticket still reads Ada Lovelace.
Remove the state variable and calculate 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 41 42 43 44 45 46 47 48 49 50 51 | |
1 | |
Now Rename first followed by Rename last gives Grace Hopper. The same two buttons cannot go wrong, because there is only one copy of the name.
The difference is not the render count — both versions render once per press, verified. It is that the derived version has no code path that produces a stale ticket.
Note
Deriving is the default, but not free when a calculation is genuinely expensive. The order is: derive first, then measure. If a render is too slow, cache the value with use_memo rather than promoting it to state — a cache adds no render, a state variable does. See You Might Not Need an Effect.
Avoid duplication in state¶
A snack menu lets you choose one item and edit the titles. This version stores the selected item as an object:
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 43 44 45 46 47 48 49 50 51 52 53 | |
1 | |
Press Choose on pretzels, then edit that input to chips. The input updates; the line below it does not:
You picked pretzels.
selected_item is a second copy of data that also lives inside items. set_items updated one copy and nothing updated the other — the same bug as the stale full name, one level deeper.
Hold the id instead and look the item up during render:
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 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | |
1 | |
Editing the selected row now updates the line below it, because selected_item is a next(...) over items, re-evaluated on every render. The state went from:
items = [{"id": 0, "title": "pretzels"}, ...]selected_item = {"id": 0, "title": "pretzels"}
to:
items = [{"id": 0, "title": "pretzels"}, ...]selected_id = 0
Only the id is essential; the rest was a copy. The same reasoning turns a selected object into an id, a filter string into its own fields, and a sorted list into an unsorted list plus a sort.
Pitfall
"Store the id, derive the object" meets ReactPy's == comparison in one place worth knowing about. A derived object handed to use_effect as a dependency compares equal every render, so the dependency holds and the effect never restarts — usually what you want. But that also means a dependency cannot detect a change that == cannot see. Verified with a plain class instance held as a dependency and mutated in place: across three presses the component re-rendered each time and the screen showed n=3, while the effect ran once and kept reporting n=0. Use dict and list, or an immutable @dataclass(frozen=True), for anything that appears in a dependency array. See Removing Effect Dependencies.
Avoid deeply nested state¶
A travel plan is planets, then continents, then countries. The tempting shape mirrors the picture. Two files make the example — app.py renders it, data.py holds the tree:
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 43 44 45 46 47 48 49 50 51 52 53 54 55 | |
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 | |
1 | |
Marking one country visited calls mark_visited, which copies the root, copies Earth, copies the continent, and copies every sibling along the way — a new object for every node between the root and the leaf. Correct, and also easy to get wrong: miss one {**node, ...} and you have mutated shared state, which in ReactPy means the update may compare equal and never render.
Flatten the tree into one dict keyed by id, with child lists of ids:
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 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 | |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
1 | |
The same update now adds one entry to one set, and finding a place is a dict lookup rather than a recursion. The rendering component still walks the tree by id, so the display is unchanged — only the update path got shorter.
A useful test for whether nesting has gone too far: count the objects an update has to rebuild to change one field. If the answer is "everything above it", flatten. If the hierarchy is genuinely part of the data and updates are rare, nesting is fine — the cost is in writes, not reads.
Deep Dive
Flattening is what databases do, and ReactPy's == comparison gives you a concrete second reason — though it rewards less than you would expect. A state update is detected by handing the new value to == (core/hooks.py, strictly_equal, falls through to == for a dict). dict.__eq__ first compares lengths, which is free, then walks entries in insertion order and stops at the first difference. So the cost depends on where the changed entry sits, not on how big the object is.
Measured on dicts of 100,000 keys, comparing a copy against its original after changing one entry: about 0.00 ms when the changed key sorts first, about 1.3 ms when it sorts last, and about 3 ms when the two dicts hold identical content. Nested dicts recurse, so a rebuilt branch is compared down to its leaves while untouched sibling branches are still compared entry by entry too — sharing the sub-dict objects does not make the comparison skip them.
The honest conclusion: comparison cost is real but small and easy to over-engineer around, so flatten for the update-code reasons above rather than for this one. Where the shapes matter, measure instead of guessing — ReactPy ships no render profiler, so count renders with a use_ref counter in the component body, as the examples on this page do.
Recap¶
- ReactPy's
use_statecompares with==: setting a new object that reads equal to the old one renders nothing. Copy objects rather than mutating them — verified, mutating leaves the screen and the data disagreeing. type(x) is not type(y)makes(1, 2)and[1, 2]different states, so pick one container type per field.- Group state that always changes together. It removes the "forgot the second setter" bug; it does not reduce renders (verified: same count both ways).
- Replace mutually exclusive booleans with one variable holding one of several values, so the impossible state is unrepresentable.
- Derive anything you can calculate during render. Both versions of the name form render the same number of times; only one of them can show a stale ticket.
- Store ids, not copies. Edit the selected row and the derived label follows while the stored one does not.
- Count how many objects an update must rebuild. If nesting forces you to rebuild the path to every leaf, flatten and key by id.
Related¶
- You Might Not Need an Effect — the same derive-don't-store rule applied to Effects
- Lifecycle of Reactive Effects —
strictly_equalin full, and why dependency arrays use== - Updating Objects in State and Updating Arrays in State — the copy idioms this page assumes
- Queueing a Series of State Updates — functional updaters, for when two handlers race
- Extracting State Logic into a Reducer — the next step once one component holds too many fields
- Use State — parameter-level reference