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
dispatchis 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 | |
1 | |
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 | |
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 | |
1 | |
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 | |
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 | |
1 | |
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 ownsuse_reducer; read it in each component that needs it. dispatchis identity-stable across renders in ReactPy (verified: oneidover 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
keydoes 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.
Related¶
- Extracting State Logic into a Reducer — the reducer rules this pattern depends on, including the frozen-first-reducer rule
- Passing Data Deeply with Context — the context mechanics, and the provider-removal trap
- Use Context — parameters, caveats, and error messages
- Use Reducer — parameter-level reference and troubleshooting
- Reusing Logic with Custom Hooks — naming and rules for the
use_tasksaccessor - Choosing the State Structure — what belongs in state before you decide who provides it