Skip to content

Use Reducer

Overview

use_reducer is a ReactPy hook that holds state behind a function: you send an action, and a reducer turns the current state plus that action into the next state.

state, dispatch = use_reducer(reducer, initial_value)

Reach for it when a component's updates are more than "set this to that" — several fields that change together, transitions that depend on what is already there, or update logic you want out of the component so it can be tested as a plain function.


Reference

use_reducer(reducer, initial_value)

Call use_reducer at the top level of your component.

from reactpy import use_reducer

Parameters

  • reducer: (state, action) -> new_state. A pure function: given the same two arguments it returns the same result, and it does not touch anything outside them. It must return a new object rather than modifying state — see the reducer must not mutate.
  • initial_value: The state to start with. Pass a function taking no arguments to build it lazily; ReactPy calls it on the first render only. See avoiding recreating the initial state.

Returns

A two-item tuple, normally unpacked:

  • state: The current state, as of the start of this render.
  • dispatch: A function that takes an action and schedules the update. The same object on every render — verified, one dispatcher across four renders — so it is safe to pass into a child or a dependency list.

Caveats

  • Define the reducer at module level. ReactPy builds the dispatcher once, on the first render, and the dispatcher closes over the reducer you passed then. A reducer defined inside the component is a new function object each render and ReactPy keeps using the first one, so any value it closes over is frozen at its first-render value. Measured: four renders, four distinct reducer objects passed in, and the state kept reporting seen_n=0. This differs from React, which re-reads the reducer every render. Anything the reducer needs must arrive in state or action. See The reducer never changes.
  • use_reducer is use_state underneath. The dispatcher calls the state setter with lambda last_state: reducer(last_state, action), so every rule from use_state applies here — replaced, never merged; equal values schedule no render; mutations vanish silently. See The reducer must not mutate.
  • The reducer runs eagerly, inside dispatch — not later during the render. Verified by ordering statements around the call. A reducer that raises is swallowed the same way any event-handler error is: logged on the server as Failed to execute event handler, with nothing in the browser console.
  • Actions are yours; ReactPy never inspects them. A dict with a "type" key is convention, not a requirement — you can dispatch a string, a number, or a tuple.
  • Like every hook, call it only at the top level — never in a loop, a conditional, or after an early return. ReactPy does not check; a hook that runs on some renders but not others produces a scrambled component and a cryptic IndexError.

Usage

Managing complex state with a reducer

One reducer holds the whole shape, and each behaviour is a branch chosen by the action. Because it is an ordinary function of two arguments, you can test it without rendering anything.

Press Add task twice, task 1, then Clear done, and watch the reducer's branches run:

 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
def todos_reducer(state, action):
    """One function per behaviour, chosen by the action's type.

    Defined at module level on purpose: ReactPy builds the dispatcher once, on
    the first render, and keeps that reducer -- a reducer defined inside the
    component would keep its first-render closure forever.

    Every branch returns a NEW dict. Returning the mutated `state` would be
    dropped without a word.
    """
    if action["type"] == "add":
        return {
            **state,
            "items": [*state["items"], {"text": action["text"], "done": False}],
        }
    if action["type"] == "toggle":
        items = [
            {**item, "done": not item["done"]} if i == action["index"] else item
            for i, item in enumerate(state["items"])
        ]
        return {**state, "items": items}
    if action["type"] == "clear_done":
        return {**state, "items": [i for i in state["items"] if not i["done"]]}
    return state


@component
def todo_list():
    state, dispatch = use_reducer(todos_reducer, {"items": []})

    async def handle_add_click(event):
        dispatch({"type": "add", "text": f"task {len(state['items']) + 1}"})

    async def handle_clear_done_click(event):
        dispatch({"type": "clear_done"})

    def make_toggle(index):
        # A factory rather than a bound-argument lambda: a handler must be a
        # coroutine, and a coroutine cannot be a lambda.
        async def toggle(event):
            dispatch({"type": "toggle", "index": index})

        return toggle

    return html.div(
        html.h1(f"To-do ({sum(1 for i in state['items'] if not i['done'])} left)"),
        html.ul(
            [
                html.li(
                    {"key": i},
                    html.button(
                        {"onClick": make_toggle(i)},
                        state["items"][i]["text"],
                    ),
                )
                for i in range(len(state["items"]))
            ]
        ),
        html.button(
            {"onClick": handle_add_click},
            "Add task",
        ),
        html.button({"onClick": handle_clear_done_click}, "Clear done"),
    )
1
# TODO

Three things to notice in todos_reducer. It is at module level, so the closure trap above never arises. Every branch builds a new dict with {**state, ...} — none of them mutate. And the fallback return state makes an unhandled action a deliberate no-op rather than a crash.

The reducer must not mutate

This is the failure mode most likely to cost you an afternoon, and it is silent. A reducer that modifies state and returns it hands back the very object ReactPy already holds; the comparison finds them equal and schedules nothing. No error, no warning, nothing in the console — the button just stops working while your data quietly changes underneath.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def counter_reducer(state, action):
    # Always return a NEW object. `use_reducer` is built on `use_state`, which
    # compares what you return against what it already holds and schedules no
    # render when the two are equal. Mutating `state` here and returning it
    # would update the data, schedule nothing, and fail with no error and
    # nothing in the browser console.
    return {**state, "count": state["count"] + 1}


@component
def counter():
    state, dispatch = use_reducer(counter_reducer, {"count": 0})

    async def handle_click(event):
        dispatch({"type": "inc"})

    return html.div(
        html.h1(state["count"]),
        html.button({"onClick": handle_click}, "Add 1"),
    )
1
# TODO

Return {**state, "count": state["count"] + 1}, not state["count"] += 1; return state. The same applies one level down: {**state, "items": state["items"] + [new]}, never state["items"].append(new). See Updating Objects in State and Updating Arrays in State.

The reducer never changes

Because the first reducer is the only one that ever runs, a reducer that closes over component state reads a value frozen at the first render — permanently, with no warning.

Run this, press Add step twice, then Bump step, then Add step again:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@component
def stepper():
    step, set_step = use_state(1)

    def reducer(state, action):
        # BUG: this reducer closes over `step`, but ReactPy builds the
        # dispatcher once, on the first render, and keeps that reducer
        # forever. Bump `step` to 3 and this keeps adding 1.
        return {**state, "total": state["total"] + step}

    state, dispatch = use_reducer(reducer, {"total": 0})

    async def handle_click(event):
        dispatch({"type": "add"})

    async def handle_step_click(event):
        set_step(step + 1)

    return html.div(
        html.p(f"total: {state['total']}"),
        html.p(f"step: {step}"),
        html.button({"onClick": handle_click}, "Add step"),
        html.button({"onClick": handle_step_click}, "Bump step"),
    )
1
# TODO

step goes to 2 on screen, but the total keeps rising by 1. The reducer still holds the step from the render that created it. Two ways out: keep the reducer at module level and put step in the state, or send it in the action — dispatch({"type": "add", "amount": step}).

Avoiding recreating the initial state

Pass a function and ReactPy calls it once; pass a call and it runs on every render, with every result but the first discarded. Measured across four renders: 1 call versus 4. The counter on screen reports the total, and it stays at 1 no matter what you press.

 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
loads = []  # demo-only: shows how often the initializer actually runs


def load_config():
    """Stand-in for anything slow: reading a file, querying a database."""
    loads.append(1)
    return {"rows": [{"name": n} for n in ("alpha", "beta", "gamma")], "selected": None}


def reducer(state, action):
    if action["type"] == "select":
        return {**state, "selected": action["name"]}
    return state


@component
def report():
    # Pass a function as the initial state and ReactPy calls it once, on the
    # first render. Passing `load_config()` instead would call it on every
    # render and throw every result but the first away.
    state, dispatch = use_reducer(reducer, load_config)

    async def handle_click(event):
        dispatch({"type": "select", "name": state["rows"][0]["name"]})

    return html.div(
        html.h1(f"{len(state['rows'])} rows"),
        html.p(f"selected: {state['selected'] or '-'}"),
        html.p(f"initializer ran {len(loads)} time(s)"),
        html.button(
            {"onClick": handle_click},
            "Select first",
        ),
    )
1
# TODO

use_reducer or use_state?

Prefer use_state. It is shorter and there is no reducer-closure rule to remember. Switch when one of these is true:

  • Several pieces of state always change together, and updating them through separate setters lets the component render in between with an inconsistent mix.
  • The next state depends on the current one in a way you keep writing wrong.
  • The update logic has grown branches you want to name, or unit-test as a plain function.
  • Different parts of a tree need to trigger the same transitions — passing dispatch around is tidier than passing several setters.

Troubleshooting

My dispatch does nothing

Check the reducer's return value first — this is almost always a mutation. return state after modifying state schedules nothing. Also check for a branch that returns the state unchanged: an action whose "type" no branch matches falls through to your return state, which is correctly a no-op.

My reducer sees an old value of some state

You defined it inside the component, so ReactPy is running the first-render version with the first-render closure. Move it to module level and pass the value through state or the action.

The initial value seems to be rebuilt every render

You passed build_initial_state() instead of build_initial_state. ReactPy needs the function itself to know it may skip the work.

My reducer raised and nothing said so

Expected. Reducer errors land in the server log as Failed to execute event handler and are otherwise swallowed; the state is left unassigned. Look at the server output, not the browser console.

I want an updater function instead of an action

That is what use_state gives you: set_value(lambda previous: ...). If that is all you need, use_reducer is ceremony you do not owe yourself.

Authors: Archmonger
Reactive Python and affiliates.