Skip to content

Lifecycle of Reactive Effects

Overview

An Effect is a piece of synchronization code attached to a render. It runs, it may clean up after itself, and it runs again if its inputs change. Understanding that lifecycle — not a memorized list of rules — is what makes Effects predictable in ReactPy.

You will learn

  • How an Effect setup and clean-up pair forms one synchronization
  • Which values an Effect "reacts" to, and what happens to the ones it doesn't
  • Why a dependency list, not the Effect's own text, decides re-synchronization
  • How Effects are ordered, and what is not guaranteed
  • Why a broken Effect reports itself nowhere the user can see

The lifecycle of an Effect

Think of an Effect as a synchronization process between a value your component holds and something outside ReactPy. Each Effect has a setup and, sometimes, a clean-up:

 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
@component
def room_view(room):
    def effect():
        # Setup: ReactPy runs this when the Effect is created.
        close = server.connect(room)

        # Clean-up: ReactPy runs this before the *next* run of this same
        # Effect, and again when the component unmounts. Together they make
        # one synchronization: connect this room, then leave it.
        def cleanup():
            close()

        return cleanup

    use_effect(effect, dependencies=[room])

    return html.p(f"{room} -- open connections: {server.open_count}")


@component
def app():
    room, set_room = use_state("lobby")

    return html.div(
        html.button(
            {"onClick": lambda event: set_room("dev" if room == "lobby" else "lobby")},
            f"Leave {room}",
        ),
        html.p("(server log is printed on the server console)"),
        room_view(room),
    )
1
# TODO

Verified against the server's own log: mount produces connect lobby; pressing Leave lobby produces close lobby then connect dev; and the last connection closes on unmount. The open-connection count never climbs above 1.

Read the pair as a single unit — "be connected to this room". Setup and clean-up are not two independent callbacks: ReactPy runs the clean-up of a run right before that run's successor sets up, using the same captured state. So switching rooms means leave the old room, join the new one, in that order, and the process is complete no matter how many times the inputs change.

Why synchronization may need to happen more than once

The value being synchronized can change while the component is on screen: room above, a search query, an auth token. Every such change means the previous synchronization is describing the past, so ReactPy tears it down and starts a new one.

That is why the clean-up is not optional boilerplate. In a browser, forgetting it leaks one subscription until the tab closes. On a ReactPy server it leaks until the process exits — and the leaked callback still holds your component, still fires, and keeps a connection or file handle alive for a user who left hours ago.

How ReactPy re-synchronizes an Effect

When a dependency changes, ReactPy runs this sequence:

  1. The component renders and registers the Effect again.
  2. A new Effect task starts, and its first act is to run the previous run's clean-up — core/hooks.py, use_effect calls run_effect_cleanup before invoking your function.
  3. Your setup runs and returns the next clean-up.

Two consequences worth internalizing:

  • The clean-up runs on the new task, not the old one. It sees the previous run's captured values, which is exactly what a teardown needs.
  • The old task is not cancelled. A sync Effect's task parks on stop.wait() and is only reaped when the component unmounts. Measured: three dependency changes leave 4 live Effect tasks where there was 1, and all 4 disappear at unmount. The clean-ups still ran in the right order; the parked tasks are simply waiting for a signal that will come at unmount. For a long-lived component re-synchronizing constantly, that is memory you are paying for.

Thinking from the Effect's perspective

When you read an Effect, read it as the outside world would. The code above says: "while this component is on screen and room is X, stay connected to X." Everything in that sentence is checkable:

Sentence part Where it comes from
"while this component is on screen" the component's mount/unmount
"and room is X" the dependency list
"stay connected to X" setup
(leaving) the returned clean-up

If part of that sentence is a lie, the Effect is wrong — not the dependency list, not the linting setup. dependencies is how you spell "and room is X"; it is not a performance knob.

Each Effect represents a separate synchronization process

Two Effects in one component are two processes, not one with two steps. Keep them apart when their synchronization targets differ, and let each own its own clean-up:

use_effect(connect_to_room, dependencies=[room])
use_effect(subscribe_to_feed, dependencies=[user_id])

Do not merge them because they live in the same component: merging means a room change tears down the feed subscription too. Two Effects in the same component run in declaration order, so ordering within a component is available to you when one genuinely depends on the other's setup having happened.

Effects "react" to reactive values

Every value in the component body — props, state, anything computed from them — belongs to one render. An Effect created by that render reads that render's values and never sees a later one. The dependency list is how you say which values should trigger a new Effect.

Here an Effect reports an error flag but leaves it out of the dependency list:

 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
@component
def counter():
    count, set_count = use_state(0)
    show_error, set_show_error = use_state(True)
    reports, set_reports = use_state([])

    def effect():
        # Reads `show_error`, but dependencies=[count] never mentions it. So
        # flipping show_error does not re-synchronize, and this keeps
        # reporting whatever show_error was when the Effect was created.
        line = f"count={count} reported_error={show_error}"
        log.append(line)
        set_reports(lambda previous: [*previous, line])

    use_effect(effect, dependencies=[count])

    return html.div(
        html.h1(count),
        html.button({"onClick": lambda event: set_count(count + 1)}, "Add 1"),
        html.button(
            {"onClick": lambda event: set_show_error(not show_error)},
            f"Toggle error (currently {show_error})",
        ),
        html.p("Effect reports:"),
        html.ul([html.li({"key": index}, line) for index, line in enumerate(reports)]),
    )
1
# TODO

Verified: pressing Toggle error re-renders the component and runs the Effect zero times — the list says dependencies=[count], so nothing happens. Press Add 1 and the Effect runs, and this time it reports the new value of the flag, because a brand-new Effect was created by that render.

That is the precise meaning of "stale": the Effect reports values from the render that created it. It does not freeze at mount, and it does not read the latest — it reads whichever render it was born in. Adding the missing value to the list is the fix.

A local function as a dependency never counts as changed

This one bites hard, and it contradicts the docstrings. ReactPy compares dependencies with core/hooks.py, strictly_equal — not by id. For two locally defined functions it compares their code: same co_* attributes means same dependency. So a def re-created on every render is never detected as changed:

 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
@component
def counter():
    count, set_count = use_state(0)
    label, set_label = use_state("count")
    runs, set_runs = use_state([])

    def describe():
        # A fresh function object on every render -- but ReactPy compares two
        # such local functions by their *code*, not by identity
        # (core/hooks.py, strictly_equal). So `describe` never counts as
        # changed, and the Effect below keeps the closure it captured first.
        return f"{label}={count}"

    def effect():
        line = f"effect ran, describe() -> {describe()}"
        log.append(line)
        set_runs(lambda previous: [*previous, line])

    use_effect(effect, dependencies=[describe])

    return html.div(
        html.h1(f"{label}: {count}"),
        html.button({"onClick": lambda event: set_count(count + 1)}, "Add 1"),
        html.button({"onClick": lambda event: set_label(label + "!")}, "Rename"),
        html.p("Effect runs:"),
        html.ul([html.li({"key": index}, line) for index, line in enumerate(runs)]),
    )
1
# TODO

Verified: press Add 1 twice and Rename once — the heading shows count!!: 2, yet the Effect has run exactly once and describe() still returns 'count=0'. The Effect is permanently frozen on its first closure, and nothing warns you.

The same rule bites the other way: a fresh [1, 2] or {"a": 1} that compares equal is also "unchanged", so rebuilding an options dict on every render will not re-trigger an Effect that lists it. Depend on the scalar values you actually use, not on containers or local functions.

What an Effect with empty dependencies means

dependencies=[] does not mean "run once, like a componentDidMount". It means create this Effect and never replace it — including its body:

def effect():
    print(f"body sees label={label}")

use_effect(effect, dependencies=[])

Verified: change label three times and the component re-renders three times; the Effect never runs again, and when something else finally forces it, it prints the first render's label. An [] Effect is sealed at mount. Reach for it only when the Effect genuinely reads nothing but stable values (module constants, a setter, a ref).

The four settings, restated with their lifecycle meaning:

Value Meaning
dependencies=[a, b] Re-synchronize when a or b changes.
dependencies=[] Synchronize once, at mount; the body is never replaced.
dependencies=None Re-synchronize after every render.
omitted Infer the list from the closure's free variables — then compare with ==, so a rebuilt-but-equal value will not re-trigger.

All variables declared in the component body are reactive

Props, state, and anything derived from them are per-render values, so they belong in the dependency list whenever the Effect reads them. Module-level constants and imported singletons do not: they are the same object for every render and every connection.

Careful with the middle case. A dict built at module level looks like a constant and is not safe to mutate — every connection shares it (VDOM Mutations). And a mutable default argument is shared too. Neither is a dependency, because neither changes; both are bugs if you write to them.

Hooks are unusable inside an effect body

def effect():
    theme = use_context(theme_context)   # RuntimeError

Effects run in their own task, after rendering, with no life cycle hook on the stack. use_context, use_state, and every other hook raise RuntimeError: No life cycle hook is active. Are you rendering in a layout? — and that traceback reaches only the server log. Read what you need during render and let the closure carry it:

theme = use_context(theme_context)

def effect():
    apply(theme)

use_effect(effect, dependencies=[theme])

What to do when you don't want to re-synchronize

Three legitimate reasons, three different answers.

The value is genuinely constant. Import it, define it at module level, or hoist it out of the component. Then it is not a dependency because it cannot change:

POLL_INTERVAL = 5


@component
def poller(topic):
    async def effect():
        ...

    use_async_effect(effect, dependencies=[topic])

You want the latest value without restarting the process. Keep it in a ref and read .current — the box persists across renders, so a long-lived Effect always reads the newest assignment (Synchronizing with Effects).

You want to switch the process off. Do not delete the hook call — that changes the hook order and corrupts every hook after it. Pass a flag and branch inside the Effect:

 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
@component
def feed(topic, enabled):
    def effect():
        if not enabled:
            # Nothing to set up, and nothing to clean up. The hook call below
            # still happens, so the hook order never changes.
            return None

        print(f"subscribe {topic}")

        def cleanup():
            print(f"unsubscribe {topic}")

        return cleanup

    use_effect(effect, dependencies=[topic, enabled])

    return html.p(f"{topic}")


@component
def app():
    topic, set_topic = use_state("news")
    enabled, set_enabled = use_state(True)

    return html.div(
        html.button(
            {"onClick": lambda event: set_enabled(not enabled)},
            "Stop" if enabled else "Start",
        ),
        html.button(
            {
                "onClick": lambda event: set_topic(
                    "sports" if topic == "news" else "news"
                )
            },
            "Switch topic",
        ),
        feed(topic, enabled),
    )
1
# TODO

Verified: Stop runs unsubscribe news; while stopped, switching topics logs nothing (correct — there is nothing to synchronize); and Start then runs subscribe sports for the new topic. The hook call always happens; only the setup is conditional. Note the dependency list includes enabled — leaving it out would mean toggling did nothing at all.

Do not omit a dependency to dodge a loop

An Effect that re-triggers itself is telling you something. Measured: an Effect that sets a piece of state it also lists as a dependency rendered 86 times before the probe stopped. Deleting the dependency silences the symptom and leaves you with an Effect that reports the past. Fix the loop — usually by moving the value into the dependency list and making the setup idempotent, or by deriving the value during render instead (You Might Not Need an Effect).

ReactPy will not verify your dependencies

React ships a compiler that checks each Effect against its dependency list. ReactPy has reactpy-flake8, and it is far narrower than people expect. Verified against the installed reactpy_flake8.run.run_checks:

What you wrote Reported?
a hook called inside an if or for yes — REACTPY102
an inline lambda Effect missing a variable from dependencies yes — REACTPY202, and only with exhaustive_hook_deps=True
a named def Effect missing a variable from dependencies no
a @use_effect-decorated function missing a variable no
use_memo / use_callback missing a dependency no

So the linter will catch a hook in a branch, and will catch a missing dependency only in the one form nobody writes. The stale-value class of bug is entirely on you.

Two habits replace the missing compiler:

  1. Read the Effect and list the names. Every free variable in the body should appear in dependencies, or be provably module-level. This is mechanical — do it once per Effect at write time.
  2. Test the re-synchronization, not just the mount. Render, change the input, assert the outside world changed:

    async def test_switching_room_reconnects():
        async with Layout(app()) as layout:
            await layout.render()
            ...  # deliver the click that changes the room
            assert server.log == ["connect lobby", "close lobby", "connect dev"]
    

    A reactpy.core.layout.Layout in pytest is enough — no browser, and it catches exactly the bugs the linter cannot see.

The order Effects run in

Verified output for a parent with two children, each declaring two Effects:

beta: first effect
beta: second effect
alpha: first effect
alpha: second effect
panel: my own effect
 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
@component
def leaf(name):
    @use_effect
    def first():
        print(f"{name}: first effect")

    @use_effect
    def second():
        print(f"{name}: second effect")

    return html.p(name)


@component
def panel():
    @use_effect
    def parent_effect():
        print("panel: my own effect")

    # Two children: the SECOND one's effects run first.
    return html.div(leaf("alpha"), leaf("beta"))


@component
def app():
    return html.div(
        html.h1("Watch the server console"),
        panel(),
    )
1
# TODO

Three rules, and nothing else:

  • Children before parent. A parent's own Effects run after everything below it has set up.
  • Siblings in reverse source order. beta before alpha. This surprises everyone and is worth knowing if two siblings write to the same external resource.
  • Within one component, declaration order. This one you can rely on.

Do not build anything on the first two. They are behaviour, not contract — the reverse-sibling rule in particular is an artifact of how effects are collected, and code that depends on a particular cross-component order is code that will break. Within a component, order Effects deliberately; across components, make each Effect self-contained.

Async Effects: cancellation is not instant

use_async_effect wraps your coroutine in a task. On re-synchronization the old task is cancelled, which means it stops at its next await — not before, and not necessarily in time.

Two things follow, both measured.

Two runs can be alive at once. Switch topics three times quickly, before any 0.4 s poll resolves, and the log is:

start polling news
cancelled news
start polling sports
start polling news          # the older run is still awaiting
cancelled news
start polling sports
finished polling sports
finished polling sports

Verified, and identical across three repeats: two runs are simultaneously awaiting. Cancellation happens when the successor task starts, so a run that has already passed its awaits cannot be recalled — and notice the two finished lines belong to the same topic.

A superseded run can still write state. With a 0.4 s source and four rapid switches (a → b → c → d), the runs that actually applied their result were ['DATA FOR b', 'DATA FOR d'] — a and c were cancelled, b and d completed and called their setters. Reproducible across runs.

So cancellation is a bias toward the newest value, not a guarantee. If a stale write would be damaging, do not rely on being cancelled — tag the data with what produced it and check on write:

loaded, set_loaded = use_state({"name": None, "data": {}})

async def effect():
    data = await fetch(name)
    if name == expected_name.current:   # still the value we care about?
        set_loaded({"name": name, "data": data})

Put teardown that must run in the cancellation branch, and re-raise:

async def effect():
    handle = open_connection()
    try:
        while True:
            await handle.poll()
    except asyncio.CancelledError:
        handle.close()
        raise

Swallowing CancelledError leaves the task in a state asyncio did not expect.

A sync Effect blocks the whole connection

use_effect runs on the connection's event loop. Measured: an Effect calling time.sleep(0.4) made the next click take 0.40 s to be served, and every other handler on that connection waited behind it. Only the browser's own local typing stays responsive. Long or blocking work belongs in use_async_effect, with blocking calls wrapped in asyncio.to_thread (Communicating Data Between Server and Client).

When an Effect fails, nothing reports it

This is the section that saves the most hours. An Effect body that raises is invisible in three places at once:

  • the page looks completely fine,
  • the browser console is clean,
  • and — the part that misleads people — the server log stays silent too.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@component
def dashboard():
    refreshes, set_refreshes = use_state(0)

    def effect():
        # Suppose this reads a metric from a database and the query fails.
        #
        # The page does not change, the browser console stays clean, and no
        # error reaches the model. The ONLY trace is a traceback on the
        # server -- and, as written here, it does not even appear until the
        # component unmounts. Test your Effects; the UI will not report them.
        msg = "metric query failed"
        raise RuntimeError(msg)

    use_effect(effect, dependencies=[refreshes])

    return html.div(
        html.h1("Dashboard looks fine"),
        html.button({"onClick": lambda event: set_refreshes(refreshes + 1)}, "Refresh"),
    )
1
# TODO

Measured with a log handler attached to the reactpy logger: after mount, after three dependency changes, and after half a second idle, zero log records. The single ERROR Error in effect record appears only when the component unmounts — because core/_life_cycle_hook.py, affect_component_will_unmount is where the Effect tasks are finally awaited and their exceptions gathered (core/hooks.py parks each task on stop.wait(), so nobody looks at its result before then).

Consequences to design around:

  • Never diagnose an Effect from the page. Read the server log, and read it after navigating away — that is when failures surface.
  • Assert the external world in tests. A render assertion passes happily while your Effect is throwing; assert that the connection happened, the row was written, the file exists.
  • Catch inside the Effect and set state when a failure should be visible to the user. ReactPy will not do it for you:

    def effect():
        try:
            set_metrics(fetch_metrics())
        except Exception as error:
            set_error(str(error))   # now the user can see it
    

Contrast with the failure modes that are reported, so you can tell which one you have:

Symptom Cause
Blank region, error in the model (debug on) or a server traceback the component body raised — e.g. an async def passed to use_effect
ValueError: Cell is empty in the model an Effect registered above the state it reads
Page looks fine, log silent until unmount the Effect body raised
Page looks fine, log clean forever, Effect never runs wrong dependencies

Recap

  • An Effect is a synchronization process: setup, clean-up, and a dependency list that says when to redo it.
  • The clean-up of a run executes on its successor's task, before that successor's setup — so a re-synchronization is leave-then-join, in order.
  • An Effect reads the values of the render that created it. Not the latest, not necessarily the first.
  • The dependency list, not the Effect's own text, decides re-synchronization. dependencies=[] seals the body at mount.
  • Dependencies compare with == via strictly_equal, so local functions and rebuilt-but-equal containers never count as changed — and a frozen Effect is silent.
  • ReactPy will not check your dependency list: reactpy-flake8 reports hooks in branches, and missing dependencies only for inline lambdas. Write the list yourself and test the re-synchronization.
  • Children's Effects run before the parent's, siblings in reverse, and within a component in declaration order. Only the last is safe to depend on.
  • Async cancellation lands at the next await: runs can overlap and a superseded run can still write. Guard the write; don't trust the cancel.
  • A raising Effect shows nothing in the page, nothing in the browser, and nothing in the log until unmount. Verify Effects by asserting their effect on the world.
Authors: Archmonger