Skip to content

Components and Hooks must be pure

Overview

A component is a formula: given the same arguments, it must return the same VDOM, and it must change nothing outside itself. This page states that contract precisely — for components and for hooks — and names the places ReactPy expects side effects instead.

The short version:

  • A component may read its arguments, call hooks, and return VDOM. It must not mutate anything that existed before it was called, and its output must not depend on anything it did not receive.
  • A hook follows the same rule, plus one of its own: it must be called in the same order on every render — see Rules of Hooks.

This is not a style preference. ReactPy calls your component at times and frequencies you do not control, and rendering is where the shared-state bug below lives — because in ReactPy components run on a server, inside one long-lived Python process.


The contract

A component body is allowed to:

Do Example
Read its own arguments html.p(title)
Call hooks count, set_count = use_state(0)
Compute from those values html.p(f"{count} item(s)")
Define functions it hands to the client {"onClick": save}
Call other components html.div(None, Row(item))

A component body is not allowed to:

Don't Why it fails
Assign to a module-level or global variable Shared by every connection in the process; see The server makes this worse
Mutate an object it received as an argument The caller still holds it, so the change is visible outside the render
Mutate a value read from use_state The comparison that decides whether to re-render can drop an in-place mutation silently
Read the clock, random, or the environment Same inputs, different output — the formula is broken
Do I/O: open, requests, a database, print Runs on every render, at a frequency you do not control
Start a task, thread, or timer Leaks one per render

The two halves of the rule are independent. random.choice mutates nothing and is still impure of output; writing a module-level dict is an outside effect whether or not it changes what you return.

The pair below is the difference, side by side. Both render identically on the first pass. The first one counts renders in a module-level variable, so the number it prints is a property of the process, not of the component — press the button twice in two browser tabs and the two pages disagree about what "the same render" produced:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Module-level state is shared by every connection and survives across renders,
# so `total` is not part of the component's inputs at all. Two browsers, or two
# renders of the same component, see the same counter.
total = 0


@component
def impure_counter():
    global total
    count, set_count = use_state(0)

    # Render mutates module state: an outside effect, and a value that changes
    # what the next render returns even though nothing the component received
    # changed. Nothing warns about this -- the symptom is a number that does not
    # match the button presses.
    total += 1

    return html.div(
        html.h1(count),
        html.p(f"renders (module-wide): {total}"),
        html.button({"onClick": lambda event: set_count(count + 1)}, "Add 1"),
    )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@component
def counter():
    count, set_count = use_state(0)

    # Nothing outside this component is touched. `count` comes from use_state,
    # which ReactPy keeps per component instance -- so two connections to this
    # same component each have their own number, and re-running the render
    # changes nothing but the value returned.
    return html.div(
        html.h1(count),
        html.button({"onClick": lambda event: set_count(count + 1)}, "Add 1"),
    )
1
# TODO

The server makes this worse

In a browser, module-level state belongs to one user. In ReactPy it belongs to every connection your process serves, for the whole life of the process. Rendering into a shared container is therefore not a code smell but a data leak — and it produces no error.

Three separate layouts, three separate visitors, one module-level dict:

SESSION = {}


@component
def account(name):
    SESSION.setdefault("cart", [])
    SESSION["cart"].append(f"{name}-item")
    return html.div(f"{name}: cart has {len(SESSION['cart'])} item(s)")

Measured output from three independent layouts:

visitor A: cart has 1 item(s): ['A-item']
visitor B: cart has 2 item(s): ['A-item', 'B-item']
visitor C: cart has 3 item(s): ['A-item', 'B-item', 'C-item']

Nothing crashes, each page looks plausible, and every visitor after the first sees other people's data. The same failure appears with total += 1 at module scope, a module-level cache, or a mutable default argument on a component.

Per-user state belongs in use_state or use_reducer, which ReactPy keeps per component instance. State genuinely shared across connections belongs in an explicit store you pass in — through context or an argument — never in a bare module global that rendering reaches for.


Where side effects belong

Need Put it in
React to a click, keystroke, submit An event handler (Responding to Events)
Run once on mount, or when values change use_effect (Use Effect)
Await something outside the render use_async_effect (Use Async Effect)
Read a request's headers or URL use_connection, use_scope, use_location
Build a value for the render, only when inputs change use_memo (Use Memo)
Log for debugging use_debug_value, or a logging logger outside the render
Load data at process start Executor startup (ReactPy)

An event handler is the default answer. It is defined inside the component but runs later, in response to something the user did, so it may do anything a pure function may not.

use_effect is the escape hatch, not the habit

Effects run after the render, which makes them correct for touching the outside world and awkward for expressing logic. Most of the data you would reach for an effect for can be computed during the render instead. If you find yourself writing an effect to derive a value, stop and compute it in the body.


Hooks must be pure too

A hook body is a function your component calls during its render, so it inherits every rule above, plus these:

  • No global, no module mutation. Same leak, harder to see because the hook is shared by several components.
  • No time, no randomness. use_memo(lambda: random.random(), []) fixes each instance to whatever the first render produced. Pass the value in as an argument instead.
  • No I/O. A custom hook that queries a database runs that query on every render of every component that uses it.
  • No conditional hook calls, and no hooks in loops. That is the Rules of Hooks, and it is the one a reviewer can catch by reading.

A custom hook that needs the outside world should take the data as an argument, or expose an event handler that does the work when called.


What ReactPy cannot do with purity

React skips re-rendering a component whose props are unchanged — safe because pure functions are safe to cache. ReactPy has no such bailout: a child component re-runs whenever its parent renders, no matter how pure it is.

There is use_memo for values and use_callback for function identity, which reduce the work a render does, but neither stops the render. So purity here buys correctness, testability, and safety under restart — not performance. Do not add memoization on the theory that ReactPy will skip the render; it will not.

Why the rule is enforced by review, not by tests

ReactPy cannot detect impurity — reading a global is a legal Python statement. The rule is about where the language lets you put things, which is why it belongs in code review.


Testing purity

A pure component is an ordinary function, so test it as one — no browser:

import asyncio

from reactpy.core.layout import Layout


def render_model(component):
    async def go():
        async with Layout(component) as layout:
            return (await layout.render())["model"]

    return asyncio.run(go())


def test_same_arguments_same_output():
    assert render_model(Greeting("a")) == render_model(Greeting("a"))

Run each layout in its own event loop and build a fresh component instance per call. If a component renders differently the second time, or one layout's render changes another's output, it is impure and the test says so — which is exactly what happens for a component that counts its own renders.


Symptoms

You see Likely cause
Two users see each other's data Module-level state written during render
A counter is higher than the number of clicks The component rendered more often than you assumed, and it counts renders
Numbers change on reload with no user action Clock or random during render
A list mutation does nothing State mutated in place; see Updating Arrays in State
One component's render changes another's output A shared object reached through a global, a default argument, or a class attribute
Authors: Archmonger