Skip to content

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
INITIAL_TASKS = [
    {"id": 0, "text": "Packing", "done": False},
    {"id": 1, "text": "Passport", "done": True},
    {"id": 2, "text": "Camera", "done": False},
]


@component
def task_app():
    next_id, set_next_id = use_state(3)
    tasks, set_tasks = use_state(INITIAL_TASKS)

    def handle_add_task(event):
        set_tasks([*tasks, {"id": next_id, "text": "New task", "done": False}])
        set_next_id(next_id + 1)

    def handle_toggle(task_id):
        def handle_click(event):
            set_tasks(
                [
                    {**task, "done": not task["done"]}
                    if task["id"] == task_id
                    else task
                    for task in tasks
                ]
            )

        return handle_click

    def handle_delete(task_id):
        def handle_click(event):
            set_tasks([task for task in tasks if task["id"] != task_id])

        return handle_click

    return html.div(
        html.h3("Prague itinerary"),
        html.button({"onClick": handle_add_task}, "Add task"),
        html.ul(
            [
                html.li(
                    {"key": task["id"]},
                    html.span(
                        {
                            "style": {
                                "textDecoration": "line-through"
                                if task["done"]
                                else "none"
                            }
                        },
                        task["text"],
                    ),
                    html.button({"onClick": handle_toggle(task["id"])}, "Toggle"),
                    html.button({"onClick": handle_delete(task["id"])}, "Delete"),
                )
                for task in tasks
            ]
        ),
        html.p({"id": "summary"}, f"{len(tasks)} tasks, next id {next_id}"),
    )


@component
def app():
    return task_app()
1
# TODO

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
INITIAL_TASKS = [
    {"id": 0, "text": "Packing", "done": False},
    {"id": 1, "text": "Passport", "done": True},
    {"id": 2, "text": "Camera", "done": False},
]


def tasks_reducer(state, action):
    """One place that knows how the task list may change."""
    kind = action["type"]
    if kind == "added":
        task = {"id": state["next_id"], "text": "New task", "done": False}
        return {
            **state,
            "tasks": [*state["tasks"], task],
            "next_id": state["next_id"] + 1,
        }
    if kind == "toggled":
        return {
            **state,
            "tasks": [
                {**task, "done": not task["done"]}
                if task["id"] == action["id"]
                else task
                for task in state["tasks"]
            ],
        }
    if kind == "deleted":
        return {
            **state,
            "tasks": [task for task in state["tasks"] if task["id"] != action["id"]],
        }
    raise ValueError(f"unknown action type {kind!r}")


@component
def task_app():
    state, dispatch = use_reducer(tasks_reducer, {"tasks": INITIAL_TASKS, "next_id": 3})

    def handle_add_task(event):
        dispatch({"type": "added"})

    def handle_toggle(task_id):
        def handle_click(event):
            dispatch({"type": "toggled", "id": task_id})

        return handle_click

    def handle_delete(task_id):
        def handle_click(event):
            dispatch({"type": "deleted", "id": task_id})

        return handle_click

    return html.div(
        html.h3("Prague itinerary"),
        html.button({"onClick": handle_add_task}, "Add task"),
        html.ul(
            [
                html.li(
                    {"key": task["id"]},
                    html.span(
                        {
                            "style": {
                                "textDecoration": "line-through"
                                if task["done"]
                                else "none"
                            }
                        },
                        task["text"],
                    ),
                    html.button({"onClick": handle_toggle(task["id"])}, "Toggle"),
                    html.button({"onClick": handle_delete(task["id"])}, "Delete"),
                )
                for task in state["tasks"]
            ]
        ),
        html.p(
            {"id": "summary"},
            f"{len(state['tasks'])} tasks, next id {state['next_id']}",
        ),
    )


@component
def app():
    return task_app()
1
# TODO

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
def add_by_one(state, action):
    return {"n": state["n"] + 1, "by": "add_by_one"}


def add_by_hundred(state, action):
    return {"n": state["n"] + 100, "by": "add_by_hundred"}


@component
def counter():
    version, set_version = use_state(1)

    # The component body picks a different reducer each time version changes,
    # but use_reducer built its dispatcher on the first render and reuses it.
    reducer = add_by_one if version == 1 else add_by_hundred
    state, dispatch = use_reducer(reducer, {"n": 0, "by": "-"})

    def handle_add(event):
        dispatch({"type": "add"})

    def handle_switch(event):
        set_version(2)

    return html.div(
        html.button({"onClick": handle_add}, "Add"),
        html.button({"onClick": handle_switch}, "Switch reducer"),
        html.p({"id": "readout"}, f"n={state['n']} by={state['by']} version={version}"),
    )


@component
def app():
    return counter()
1
# TODO

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
INITIAL = {"count": 0, "total": 0}


@component
def cart(item_price):
    # This reducer reads item_price from the enclosing scope. ReactPy keeps the
    # reducer from the first render, so it keeps the first price too.
    def reducer(state, action):
        if action["type"] == "add":
            return {
                "count": state["count"] + 1,
                "total": state["total"] + item_price,
            }
        return state

    state, dispatch = use_reducer(reducer, INITIAL)

    def handle_add(event):
        dispatch({"type": "add"})

    return html.div(
        html.button({"onClick": handle_add}, "Add"),
        html.p(
            {"id": "cart"},
            f"count={state['count']} total={state['total']} price={item_price}",
        ),
    )


@component
def price_control():
    price, set_price = use_state(10)

    def raise_price(event):
        set_price(50)

    return html.div(
        html.button({"onClick": raise_price}, "Raise price"),
        cart(price),
        html.p(f"parent price={price}"),
    )


@component
def app():
    return price_control()
1
# TODO

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
INITIAL = {"count": 0, "total": 0}


def cart_reducer(state, action):
    """Pure and self-contained: every value it needs arrives in the action."""
    if action["type"] == "add":
        return {
            "count": state["count"] + 1,
            "total": state["total"] + action["price"],
        }
    return state


@component
def cart(item_price):
    # Defined at module level, so there is no enclosing scope to freeze.
    state, dispatch = use_reducer(cart_reducer, INITIAL)

    def handle_add(event):
        # The handler closes over the *current* price, and hands it over.
        dispatch({"type": "add", "price": item_price})

    return html.div(
        html.button({"onClick": handle_add}, "Add"),
        html.p(
            {"id": "cart"},
            f"count={state['count']} total={state['total']} price={item_price}",
        ),
    )


@component
def price_control():
    price, set_price = use_state(10)

    def raise_price(event):
        set_price(50)

    return html.div(
        html.button({"onClick": raise_price}, "Raise price"),
        cart(price),
        html.p(f"parent price={price}"),
    )


@component
def app():
    return price_control()
1
# TODO

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 ValueError leaves the state untouched, produces zero renders, and logs nothing under the reactpy logger — only a root-logger ERROR: Failed to execute event handler line. 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 dispatch calls 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
INITIAL_TASKS = [
    {"id": 0, "text": "Packing", "done": False},
    {"id": 1, "text": "Passport", "done": True},
]


def mutating_reducer(state, action):
    """Looks fine, edits the list in place, returns it. Nothing will re-render."""
    if action["type"] == "added":
        state["tasks"].append(
            {"id": state["next_id"], "text": "New task", "done": False}
        )
        return state
    return state


@component
def task_app():
    # A fresh list per component instance: this reducer mutates the list it is
    # given, so it must not be handed a module-level one.
    state, dispatch = use_reducer(
        mutating_reducer, {"tasks": [*INITIAL_TASKS], "next_id": 2}
    )

    def handle_add_task(event):
        dispatch({"type": "added"})

    return html.div(
        html.button({"onClick": handle_add_task}, "Add task"),
        html.ul(
            [html.li({"key": task["id"]}, task["text"]) for task in state["tasks"]]
        ),
        html.p({"id": "summary"}, f"{len(state['tasks'])} tasks rendered"),
    )


@component
def app():
    return task_app()
1
# TODO

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_state needs 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 — moving next_id next to tasks above is that case.
  • Readability. With use_state the 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_state you 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 no memo.

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 five set_field actions. 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(...) and set_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 state and action. A reducer defined in the component body freezes its closure — verified charging total=20 at a displayed price=50.
  • dispatch runs 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 reactpy log entry; end the reducer with raise ValueError so unknown actions surface.
  • Several dispatches in one handler apply in order and cost one render (verified).
  • use_reducer takes two arguments; pass a callable as initial_value to build it lazily, once.
Authors: Archmonger