Skip to content

Scaling Up with Reducer and Context

Overview

A reducer keeps a component's update logic in one pure function; context carries values past components that only forward them. Together they let a deep tree read and change shared state without any wiring through props. In ReactPy there is one extra fact that makes this work cleanly: dispatch has a stable identity across renders, so it is safe to put in a context value.

You will learn

  • How to combine a reducer with context, in three steps
  • Why dispatch is safe to share through context in ReactPy
  • How to move all the wiring into a single module the UI never imports from directly

Combining a reducer with context

Start with the version where state and dispatch travel as props. Only task_form and task want dispatch, but it has to be handed to everyone in between:

 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
@component
def task_form(state, dispatch, task):
    # The only component that actually wants dispatch...
    def handle_toggle(event):
        dispatch({"type": "toggled", "id": task["id"]})

    return html.div(
        {"style": {"textDecoration": "line-through" if task["done"] else "none"}},
        task["text"],
        html.button({"onClick": handle_toggle}, "Toggle"),
    )


@component
def task(state, dispatch, task):
    # ...but must receive it and hand it on, unchanged.
    def handle_delete(event):
        dispatch({"type": "deleted", "id": task["id"]})

    return html.li(
        {"key": task["id"]},
        task_form(state, dispatch, task),
        html.button({"onClick": handle_delete}, "Delete"),
    )


@component
def task_list(state, dispatch):
    # ...and this one forwards both, without reading either.
    return html.ul(
        [task(state, dispatch, item, key=item["id"]) for item in state["tasks"]]
    )


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

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

    return html.div(
        html.h3("Prague itinerary"),
        html.button({"onClick": handle_add}, "Add task"),
        task_list(state, dispatch),
        html.p({"id": "summary"}, f"{len(state['tasks'])} tasks"),
    )


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

Read the signatures: task_list(state, dispatch) receives both and reads neither — it only builds children and passes both along. task forwards state untouched to task_form, which uses only dispatch. Every new layer in the middle copies the same two parameters.

The reducer is already correct for ReactPy: module-level, reading nothing but its two arguments, returning new objects, and raising on an unknown action type. See Extracting State Logic into a Reducer for why each of those matters here.

Step 1: Create the context

One context carries both halves — the state to read and the dispatch to change it with. Keep it beside the reducer, in a module that does not import the UI:

 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
@component
def task_form(task):
    # Reads dispatch from context; no prop arrived.
    _, dispatch = use_context(TaskContext)

    def handle_toggle(event):
        dispatch({"type": "toggled", "id": task["id"]})

    return html.div(
        {"style": {"textDecoration": "line-through" if task["done"] else "none"}},
        task["text"],
        html.button({"onClick": handle_toggle}, "Toggle"),
    )


@component
def task(task):
    _, dispatch = use_context(TaskContext)

    def handle_delete(event):
        dispatch({"type": "deleted", "id": task["id"]})

    return html.li(
        {"key": task["id"]},
        task_form(task),
        html.button({"onClick": handle_delete}, "Delete"),
    )


@component
def task_list():
    # Reads only the state; dispatch never has to come through here.
    state, _ = use_context(TaskContext)
    return html.ul([task(item, key=item["id"]) for item in state["tasks"]])


@component
def task_app():
    state, dispatch = use_reducer(tasks_reducer, INITIAL_STATE)

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

    return html.div(
        html.h3("Prague itinerary"),
        html.button({"onClick": handle_add}, "Add task"),
        # One provider for the whole subtree.
        TaskContext(task_list(), value=(state, dispatch)),
        html.p({"id": "summary"}, f"{len(state['tasks'])} tasks"),
    )


@component
def app():
    return task_app()
 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
"""The reducer, the context, and the initial state — all outside the UI."""

from collections.abc import Callable
from typing import Any

from reactpy import create_context
from reactpy.types import Context

TaskState = dict[str, Any]
TaskValue = tuple[TaskState, Callable[[dict[str, Any]], None]]

# The reducer never mutates what it is given, so one shared constant is
# safe as the initial state for every instance.
INITIAL_TASKS = [
    {"id": 0, "text": "Packing", "done": False},
    {"id": 1, "text": "Passport", "done": True},
]

INITIAL_STATE: TaskState = {"tasks": INITIAL_TASKS, "next_id": 2}

# One context carries both halves: the state to read and the dispatch to change
# it with. Annotate the name, or the default narrows the value type.
TaskContext: Context[TaskValue] = create_context((INITIAL_STATE, lambda action: None))


def tasks_reducer(state: TaskState, action: dict[str, Any]) -> TaskState:
    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}")
1
# TODO

Two things to notice in tasks.py.

The context object is matched by identity, so create it once and import it from here everywhere. The annotation is load-bearing: create_context infers the value type from its default, so without Context[TaskValue] a type checker fixes the value to the default's shape and rejects a real one. Write the default first, then annotate. See Use Context.

The default value is a usable value, not None: an empty state and a dispatch that does nothing. A component rendered without a provider then renders an empty list instead of crashing when a handler fires.

Step 2: Put state and dispatch into context

Call use_reducer once, in the component that owns the state, and provide both halves:

state, dispatch = use_reducer(tasks_reducer, INITIAL_STATE)
...
TaskContext(task_list(), value=(state, dispatch))

A tuple is the simplest value that carries both. If you prefer a dict or a dataclass, the same rules apply — but keep it a fresh object each render, since ReactPy compares the provided value with ==, and an action that changes nothing should not re-render anything.

Step 3: Use context anywhere in the tree

Now the middle of the tree forgets both parameters:

@component
def task_list():
    state, _ = use_context(TaskContext)
    return html.ul([task(item, key=item["id"]) for item in state["tasks"]])

task_list no longer mentions dispatch at all, and task and task_form each call use_context themselves. Verified: this version and the prop version above are behaviourally identical — the same initial two tasks, the same row struck through by Toggle, the same count after Add task, the same rows after Delete. All three versions in this page pass the same user-journey comparison.

Note

use_context reserves no state slot, so unlike use_state it is safe to call conditionally. That is not an invitation to hide it: read it at the top of the component, the way you would a prop, so it is obvious where the value comes from.

Why dispatch is safe to share in ReactPy

In React, sharing dispatch through context is safe because React guarantees its identity. In ReactPy it is safe for a more concrete reason you can check in the source: use_reducer builds the dispatcher once with _use_const — a use_state holding the function — and returns that same object on every render. Verified: across five renders and four dispatches, id(dispatch) took exactly one value.

That matters because the dispatcher closes over the reducer, which ReactPy also freezes at the first render. A stale dispatcher is not a hazard here as long as the reducer is module-level — which is exactly the rule the reducer page already imposes. Share dispatch freely; do not share a value you expect to be re-read from a closure.

What context does not do: it is not a render optimisation. ReactPy has no memo, so when the provider re-renders with a new value, the whole subtree re-renders, including every component whose read value did not change. Context removes parameters, not work. See What context does not do.

Moving all wiring into a single file

Once the UI reads everything from context, the reducer, the context object, and even use_reducer can disappear from it. Move the whole mechanism into tasks.py, and expose exactly two names — a provider component and an accessor hook:

 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
@component
def task_form(task):
    # No props. The dispatch comes from the wiring file.
    _, dispatch = use_tasks()

    def handle_toggle(event):
        dispatch({"type": "toggled", "id": task["id"]})

    return html.div(
        {"style": {"textDecoration": "line-through" if task["done"] else "none"}},
        task["text"],
        html.button({"onClick": handle_toggle}, "Toggle"),
    )


@component
def task(task):
    _, dispatch = use_tasks()

    def handle_delete(event):
        dispatch({"type": "deleted", "id": task["id"]})

    return html.li(
        {"key": task["id"]},
        task_form(task),
        html.button({"onClick": handle_delete}, "Delete"),
    )


@component
def task_list():
    state, _ = use_tasks()
    return html.ul([task(item, key=item["id"]) for item in state["tasks"]])


@component
def task_app():
    state, dispatch = use_tasks()

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

    return html.div(
        html.h3("Prague itinerary"),
        html.button({"onClick": handle_add}, "Add task"),
        task_list(),
        html.p({"id": "summary"}, f"{len(state['tasks'])} tasks"),
    )


@component
def app():
    # One name from the wiring file: the provider component.
    return task_store(task_app())
 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
"""All the reducer, context, and wiring lives here; the UI imports two names."""

from collections.abc import Callable
from typing import Any

from reactpy import component, create_context, use_context, use_reducer
from reactpy.types import Context

TaskState = dict[str, Any]
TaskValue = tuple[TaskState, Callable[[dict[str, Any]], None]]

# The reducer never mutates what it is given, so one shared constant is
# safe as the initial state for every instance.
INITIAL_TASKS = [
    {"id": 0, "text": "Packing", "done": False},
    {"id": 1, "text": "Passport", "done": True},
]

INITIAL_STATE: TaskState = {"tasks": INITIAL_TASKS, "next_id": 2}

TaskContext: Context[TaskValue] = create_context((INITIAL_STATE, lambda action: None))


def tasks_reducer(state: TaskState, action: dict[str, Any]) -> TaskState:
    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_store(*children: Any):
    """Owns the reducer and provides it. The UI never sees the reducer."""
    state, dispatch = use_reducer(tasks_reducer, INITIAL_STATE)
    return TaskContext(*children, value=(state, dispatch))


def use_tasks() -> TaskValue:
    """The only accessor the UI needs: current state plus dispatch."""
    return use_context(TaskContext)
1
# TODO

The UI file now imports only task_store and use_tasks. It never mentions use_reducer, create_context, tasks_reducer, or TaskContext. Two consequences worth having:

  • The UI cannot reach the reducer except through an action, so there is no way to bypass it.
  • Changing how the state is stored — a different reducer, a different initial shape, even dropping context entirely — touches one file and no component.

task_store is a component that calls use_reducer and returns the provider. A component's render may return another component, so this composes without ceremony. Note that use_tasks is a plain function that calls use_context: it is a custom Hook, so name it use_* — the linter's regex is ^_*use_\w+$, and camelCase variants get a less accurate diagnostic. See Reusing Logic with Custom Hooks.

Pitfall

Because the provider is now a component, removing it from the tree unmounts everything below it and throws their state away. Do not write task_store(page()) if logged_in else page(). Keep the provider in the tree and vary only its value — verified: a conditional provider resets a leaf's state (its render counter returns to 1 and its draft text clears), while a stable key on the child does not prevent it. See Removing a provider resets the subtree below it.

Two task_store instances are two independent stores, not one shared store: verified, three presses on one side moved one=5 while the other stayed at its initial two=2. Context propagates down from the provider that happens to be above you. To genuinely share state, there must be one provider above both consumers — usually near the root.

Deep Dive

Should you also provide a derived value, such as a filtered list or a total? Compute it in the component that needs it. ReactPy re-renders the subtree whenever the provided state changes anyway, so a cached derivation saves nothing, and use_memo around it adds a dependency list to keep correct. Reserve the context value for what is genuinely shared: the state object and dispatch. For the caching rules, see You Might Not Need an Effect.

Recap

  • A reducer owns the transitions; context carries (state, dispatch) past components that would only forward them. Both are needed together — context without a reducer leaves the update logic scattered, a reducer without context leaves the props.
  • Create the context beside the reducer, at module level, with a usable default and a Context[...] annotation so the value type is not narrowed by the default.
  • Provide (state, dispatch) from one component that owns use_reducer; read it in each component that needs it.
  • dispatch is identity-stable across renders in ReactPy (verified: one id over five renders), which is what makes sharing it through context sound.
  • A module that exports a provider component and a use_* accessor lets the UI import two names and never touch the reducer.
  • Keep the provider in the tree. Removing it remounts the subtree and discards state; a key does not save it (verified).
  • One provider per shared store. Two providers are two stores (verified).
  • Context removes parameters, not renders — ReactPy has no render bailout.
Authors: Archmonger