Extracting State Logic into a Reducer
Overview¶
A component whose state is updated by many handlers spread across its body gets hard to reason about. A reducer moves every update rule into one pure function, so the handlers say only what the user did. In ReactPy there is one rule to learn on top of React's: the reducer you pass on the first render is the one ReactPy keeps, so a reducer defined inside the component body closes over frozen values.
You will learn
- What a reducer is, and how dispatching differs from setting
- Why ReactPy keeps the first reducer, and what that forbids
- Where a ReactPy reducer actually runs, and what that means for purity
- When a reducer is worth the extra code
Consolidate state logic with a reducer¶
This component holds a list of tasks and updates it from three handlers:
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 59 60 61 62 63 64 65 | |
1 | |
Each handler knows how to rebuild the list. That is fine at three handlers and one field. It stops being fine when a fourth handler appears, when a second field has to change alongside tasks, or when you are debugging which of the four produced a wrong list — and here handle_add_task already has to keep two state variables in step.
Managing state with a reducer inverts it. Instead of telling ReactPy what to do, each handler says what just happened, and one function decides what that means:
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 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | |
1 | |
Both examples are verified to behave identically — add twice, toggle, delete produces the same readouts and the same single flipped row in each. The difference is where the knowledge lives. The handlers now read as a list of user intentions:
dispatch({"type": "added"})dispatch({"type": "toggled", "id": task_id})dispatch({"type": "deleted", "id": task_id})
next_id moved into the same state object as tasks, so added bumps both in one return value. That is the first concrete gain: there is no longer a pair of setters that can be called in the wrong combination.
The reducer ends with raise ValueError(f"unknown action type {kind!r}") rather than return state. A fallthrough that silently does nothing is the hardest bug in this pattern to find, because every other symptom looks correct.
Note
use_reducer(reducer, initial_value) takes exactly two arguments. There is no third "init function" parameter as in some React versions. If building the initial state is expensive, pass a zero-argument callable as initial_value — ReactPy calls it once and stores the result, the same way use_state does.
The rule ReactPy adds: the first reducer wins¶
This is the part that differs from React, and it will bite you once. use_reducer builds its dispatcher once and reuses it, so the reducer function from the first render is the one that runs forever — core/hooks.py, use_reducer.
Swapping the reducer at runtime does nothing at all:
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 | |
1 | |
Press Switch reducer — the readout shows version=2, so the component body really did pick add_by_hundred — then press Add: by=add_by_one. Every later action still runs the original function.
The same mechanism is subtler, and much more common, when the reducer is defined inside the component:
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 | |
1 | |
Press Add once: count=1 total=10. Then Raise price, which takes the displayed price to 50 — and press Add again. You get total=20, not 60. The line reads price=50 while the arithmetic used 10.
Nothing crashed and nothing was logged. The reducer still holds the closure from the first render, in which item_price was 10. This is the frozen-effect-body problem from Lifecycle of Reactive Effects wearing a different hat, and it is the single most likely way to be quietly wrong with a ReactPy reducer.
The fix is the same instinct as always: keep the reducer pure and self-contained. Define it at module level, and let the action carry every value it needs. Handlers are not frozen — they are rebuilt each render — so a handler can read the current prop and hand it over:
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 | |
1 | |
Same buttons: Add, Raise price, Add now gives count=2 total=60 price=50. cart_reducer sits at module level with no enclosing scope to freeze, and dispatch({"type": "add", "price": item_price}) supplies the live value.
So the ReactPy rule is: a reducer must be a module-level function that reads nothing but its two arguments. That is stricter than React's "reducers must be pure", and it costs nothing — a reducer that closes over component scope is a reducer you cannot unit-test with two plain arguments anyway.
Where a ReactPy reducer runs¶
React's docs say reducers run during rendering, because React queues actions and calls the reducer at the next render. ReactPy is more direct, and the difference is worth knowing when you reach for a print statement:
handler:enter
reducer(add)
handler:exit
render
Verified trace of one click. dispatch calls your reducer immediately, inside the handler, then compares the result against the current state with == and schedules a render only if it differs — core/hooks.py, _create_dispatcher.
Three consequences:
- A reducer that raises is swallowed. Verified: dispatching an action whose branch raises
ValueErrorleaves the state untouched, produces zero renders, and logs nothing under thereactpylogger — only a root-loggerERROR: Failed to execute event handlerline. The component stays mounted and keeps working for later actions. This is the loudest failure the pattern has, and it is still quiet. - Multiple dispatches in one handler batch into one render. Verified: three
dispatchcalls in a single handler produce three reducer calls, applied in order, and one render. - Purity is not optional, but for a different reason than in React. Since the result is compared with
==, mutating and returning the same object compares equal and schedules nothing.
That last one is the most common reducer bug in ReactPy, and it is silent:
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 | |
Press Add task three times. The rendered count stays at 2 tasks rendered and the component body never runs again — verified at zero renders. The list did grow; the returned state compared equal to the old one, so ReactPy decided nothing had changed. Return new objects ({**state, "tasks": [*state["tasks"], task]}), exactly as you would with use_state.
Pitfall
return state after modifying state is a no-op, and so is any branch that falls through to return state. If an action's "type" matches no branch — a typo, a renamed constant — the dispatch is silently ignored. End the reducer with raise ValueError(...) for unknown types instead of a bare return state, so the mistake surfaces where it is made.
Comparing use_state and use_reducer¶
- Code size.
use_stateneeds less code upfront: a reducer and actions is two artefacts, not one. A reducer pays off when many handlers mutate state in similar shapes, or when one transition touches several fields at once — movingnext_idnext totasksabove is that case. - Readability. With
use_statethe update logic lives in the handlers, which is where you want it while it is three lines. When handlers grow conditionals, the reducer lets you separate how state changes from what happened. - Debugging. With
use_stateyou must find which setter was wrong. With a reducer you can log the two arguments of one function and reconstruct the session — in ReactPy the log also tells you the order, because dispatch is eager. If each action is right, the bug is in the reducer. - Testing. A module-level reducer — which ReactPy already requires — is a plain function.
tasks_reducer(state, {"type": "deleted", "id": 1})is an assertion, no rendering involved. - Render count. Neither is cheaper. Both compare with
==and both skip a render when the value is unchanged; neither bails out of rendering the component, because ReactPy has nomemo.
Use a reducer when a component has produced real bugs from the wrong update path, not as a default. Mixing is fine: use_state for a draft input, use_reducer for the form's submitted record, in the same component.
Deep Dive
ReactPy has no immer equivalent that earns its keep here, because {**state, ...} and [*items, item] are already the copy idioms a reducer needs. What is worth doing once a reducer grows past a couple of branches: make the state a frozen @dataclass. Then a mutation raises FrozenInstanceError at the line that did it instead of quietly comparing equal — verified, and with it the two halves that make this safe: strictly_equal compares dataclass instances field by field, so dispatching an action that changes nothing renders nothing, while one that changes a field renders once. Because the raise happens inside the event handler, though, it is caught like any other handler exception: it appears as a root-logger ERROR, not on screen. Use dataclasses.replace(state, ...), and read the server log. See Lifecycle of Reactive Effects for the full comparison table.
Writing reducers well¶
- One action per user interaction, not per field. A "Reset" button on a five-field form should dispatch
{"type": "reset_form"}, not fiveset_fieldactions. If you logged every action, the log should read like a transcript of the session. - Actions describe what happened, not what to change.
{"type": "toggled", "id": 3}survives a change in how toggling is displayed.{"type": "set_done", "value": True}does not, and is usually shorter written as a setter. - Put the data in the action.
{"type": "add_item", "item": item, "price": price}— this is also exactly what keeps a ReactPy reducer honest, since the reducer cannot read live values from a closure. - Keep transitions in the reducer, not in the handler. A handler that does
dispatch(...)andset_something_else(...)has two sources of truth again. - Do not dispatch during render. Dispatch is eager, so it updates the pending state while the component body is still running; the render then reflects an update that has already happened. Put it in an effect or a handler.
Recap¶
- A reducer collects every state transition in one pure function; handlers shrink to a description of what the user did.
- ReactPy keeps the first reducer it is given. Verified: swapping the reducer argument changes the body, not the behaviour.
- Therefore define reducers at module level, reading nothing but
stateandaction. A reducer defined in the component body freezes its closure — verified chargingtotal=20at a displayedprice=50. dispatchruns the reducer immediately, inside the handler, not at the next render. Verified by trace.- Mutating the state and returning it compares equal, so nothing renders — verified: three presses, zero renders, no log line. Return new objects.
- A raising reducer is swallowed with no
reactpylog entry; end the reducer withraise ValueErrorso unknown actions surface. - Several dispatches in one handler apply in order and cost one render (verified).
use_reducertakes two arguments; pass a callable asinitial_valueto build it lazily, once.
Related¶
- Choosing the State Structure — the
==comparison and the copy idioms a reducer depends on - Queueing a Series of State Updates — functional updaters, the
use_statesibling of dispatch - Lifecycle of Reactive Effects — frozen closures and
strictly_equal, the two mechanisms behind the traps on this page - Passing Data Deeply with Context — how to hand
dispatchto distant components - Use Reducer — parameter-level reference and troubleshooting