Skip to content

Synchronizing with Effects

Overview

Some components need to synchronize with an external system: send an analytics log when a page appears, keep a third-party object in step with state, or hold a connection open while a view is on screen. Effects let you run code after a render so a component can talk to something outside of ReactPy.

You will learn

  • What an Effect is, and how it differs from an event handler
  • How to declare one, and when it re-runs
  • Why ReactPy infers dependencies instead of requiring an array
  • How to write a clean-up function, and what happens if you skip it
  • How to read the newest state from a long-lived Effect

Note

Capitalized "Effect" means the ReactPy-specific idea above: a side effect caused by rendering. "side effect" keeps its ordinary programming meaning.

What are Effects and how are they different from events?

Every ReactPy component contains two kinds of logic:

  • Rendering code (introduced in Your First Component) sits at the top level of the component body. It takes props and state, does the math, and returns VDOM. Rendering code must be pure. It may only calculate; it may not do.

  • Event handlers (introduced in Responding to Events) are functions defined inside the component that ReactPy runs only when the user does something. They contain side effects — posting a form, updating a field — triggered by a specific interaction.

That covers most needs. But some work belongs to neither bucket. A chat view must connect to the server whenever it appears on screen, no matter which click or keystroke caused it to appear. There is no event for "this component was rendered", and putting the connection in the render body would break purity.

An Effect declares side effects caused by rendering itself, rather than by a particular event. It runs after the render is finished, in the connection's own task.

You might not need an Effect

Do not reach for an Effect first. Effects exist to step outside ReactPy and synchronize with something that is not ReactPy: a network connection, a queue, a third-party library, the filesystem. If your logic only adjusts one piece of state in response to another, or computes a value from state you already have, it belongs in the render — see You Might Not Need an Effect.

How to write an Effect

Three steps:

  1. Declare it. ReactPy registers the Effect for the render that created it.
  2. Say when it re-runs. By default ReactPy infers this from your closure, which is not what React does.
  3. Add a clean-up if it needs one. Everything that opens, subscribes, or schedules needs the matching close, unsubscribe, or cancel.

Step 1: Declare an Effect

Import the hook and call it at the top level of your component:

from reactpy import use_effect

Pass it a plain function that takes no arguments:

@component
def my_component():
    def effect():
        print("on screen")

    use_effect(effect, dependencies=[])
    ...

Or use it as a decorator, which is equivalent:

@use_effect
def effect():
    print("on screen")

Here is a working component. Open the server console and press Re-check: the log line appears once, on mount, and never again — the Effect does not depend on visit_count:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@component
def status_page():
    visit_count, set_visit_count = use_state(0)

    def effect():
        # Runs after this component is on screen. ReactPy runs it in the
        # connection's own task, after the render that scheduled it.
        print("status_page is on screen")

    use_effect(effect, dependencies=[])

    return html.div(
        html.h1("Service status"),
        html.p(f"This page has been opened {visit_count} times."),
        html.button(
            {"onClick": lambda event: set_visit_count(visit_count + 1)},
            "Re-check",
        ),
    )
1
# TODO

Two things about timing are worth stating precisely, because they differ from what a React tutorial will tell you:

  • An Effect is scheduled after your component's render is complete, not after the browser has painted. The task is created at the end of the render (core/_life_cycle_hook.py, affect_layout_did_render), and the update message has not necessarily reached the socket yet. Measured directly, an Effect's first line ran before layout.render() returned to the backend. So never assume the user can already see the result.
  • The Effect does not delay the render. layout.render() does not await your effect body; the two interleave on the event loop. If you need ordering, express it in state: have the Effect set state and let the next render depend on it.

Effects run on the server

There is no document and no window. An Effect cannot measure an element, focus a field, or attach a scroll listener, because there is no DOM where it runs. For browser-side behaviour see Manipulating the DOM with Refs and Creating VDOM Event Handlers.

use_effect rejects async def

TypeError: `use_effect` does not support async functions. Use use_async_effect instead.

The failure is loud with REACTPY_DEBUG on: the model comes back as {"tagName": "", "error": "TypeError: ..."} and the region renders empty (with debug off you get the empty region and a traceback in the server log only). Anything await-based belongs in use_async_effect, whose effect takes zero arguments — the 1.x async def effect(stop) shape fails silently there instead.

Step 2: Specify the Effect dependencies

Pass the dependencies argument to control when the Effect re-runs — and note that ReactPy gives this argument four meanings where React has two:

Value When the Effect runs (verified)
dependencies=[a, b] On mount, then whenever a or b changes.
dependencies=[] Once, on mount.
dependencies=None After every render.
omitted Inferred from the free variables of your closure.

That last row is the one that bites people porting code from React. In React, omitting the array means "run after every render". In ReactPy it means "re-run when the names my closure reads change" — core/hooks.py, _try_to_infer_closure_values reads func.__closure__ and uses those cells as the dependency list. Here the Effect reads count and nothing else, so Rename re-renders the component without re-running the Effect:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@component
def counter():
    count, set_count = use_state(0)
    label, set_label = use_state("clicks")
    runs, set_runs = use_state([])

    def effect():
        # This closure reads `count` and nothing else, so `count` is the
        # Effect's only dependency. It never reads `label`, so renaming
        # re-renders the component WITHOUT re-running this Effect.
        set_runs(lambda previous: [*previous, f"effect ran with count={count}"])

    use_effect(effect)

    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.ul([html.li({"key": index}, line) for index, line in enumerate(runs)]),
    )
1
# TODO

Press Rename twice and no new log line appears; press Add 1 and one appears per increment. Verified.

Dependencies are not compared by identity

The docstrings for these hooks say dependencies are compared by id. They are not. core/hooks.py, strictly_equal gates on the type first, then uses ==, and for two locally defined functions it compares their bytecode. In practice: a freshly built [1, 2] does not re-trigger an Effect that listed the previous equal list, and neither does a structurally identical local def — which means an Effect listing a local function as its dependency keeps the closure it captured first and reads stale state permanently. Both were measured. List the values, not wrappers around them.

Write the list out

Inference is invisible, so an Effect that happens to read one extra local variable quietly gains a dependency, and one that reads nothing re-runs never. If the Effect runs more or less often than you meant, look at which names the body actually reads. An explicit list removes the guesswork, and dependencies=[] and dependencies=None are honest too.

Declare state before the Effects that read it

use_effect(effect)  # effect reads `count`
count, set_count = use_state(0)

ReactPy reads the closure's cells while resolving dependencies, and count's cell is not filled yet: ValueError: Cell is empty. The component renders as a blank region, with the error in the model (debug on) or in the server log (debug off). State first, Effects after.

Step 3: Add clean-up if needed

Return a function from the Effect. ReactPy runs it before the next run of that same Effect and again when the component unmounts:

 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
@component
def chat_room(room):
    status, set_status = use_state("offline")

    def effect():
        # ReactPy runs the returned function before the *next* run of this
        # same Effect, and again when the component unmounts. A connection
        # without a clean-up is a permanent leak in a long-lived server.
        close = chat_server.connect(room)
        set_status(f"connected to {room}")

        def cleanup():
            close()
            set_status("offline")

        return cleanup

    use_effect(effect, dependencies=[room])

    return html.p(status)


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

    return html.div(
        html.button(
            {"onClick": lambda event: set_room("dev" if room == "lobby" else "lobby")},
            f"Switch room (now {room})",
        ),
        html.button(
            {"onClick": lambda event: set_connected(not connected)},
            "Leave" if connected else "Join",
        ),
        *([] if not connected else [chat_room(room)]),
    )
1
# TODO

Switch rooms and the server log reads connect lobby, close lobby, connect dev — the teardown for the old room runs before the new one starts. Press Leave and the last connection closes. Verified.

This matters more in ReactPy than in React because of process lifetime. A closed browser tab destroys everything with it. A ReactPy server does not: a subscription you forgot to release is still live after the user went home, still holding your component, still firing callbacks into a view nobody can see. The counter-proof, measured in a Layout:

Effect body Subscribers after 4 renders One publish fires After unmount
returns nothing 4 4 callbacks 4 still attached
returns unsubscribe 1 1 callback 0

Same component, same re-renders. Without the clean-up, one message ran the handler four times.

Clean-up is what makes restarts free

Whenever a dependency changes, ReactPy stops the old Effect and starts a new one. The rule of thumb is that nobody should be able to tell whether the Effect ran once or ran setup → cleanup → setup. Write it that way and restarts cost nothing; skip the clean-up and every dependency change leaks.

Reading the newest value from a long-lived Effect

Here is the trap that bites hardest with use_async_effect. An Effect's body closes over the render that created it. If that Effect then lives a long time — a polling loop, a subscription — it acts on values that are already stale.

The fix is to keep the value in a ref, assigning it during render. The ref object is the same one on every render, so a reader that wakes up later sees the newest write:

 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
@component
def connection_monitor():
    threshold, set_threshold = use_state(HIGH_THRESHOLD)
    alerts, set_alerts = use_state([])
    # Write the newest value during render so a reader that wakes up later
    # gets this render's number rather than the one captured at mount.
    latest_threshold = use_ref(threshold)
    latest_threshold.current = threshold

    async def watch():
        while True:
            await asyncio.sleep(0.2)
            # SLOW_LATENCY stands in for a real measurement.
            if SLOW_LATENCY > latest_threshold.current:
                set_alerts(append_alert)

    # `dependencies=[]` keeps ONE task alive for the component's lifetime.
    # Reading `threshold` directly here would freeze it at its first value.
    use_async_effect(watch, dependencies=[])

    return html.div(
        html.p(f"threshold: {threshold}"),
        html.button(
            {
                "onClick": lambda event: set_threshold(
                    LOW_THRESHOLD if threshold == HIGH_THRESHOLD else HIGH_THRESHOLD
                )
            },
            "Toggle threshold",
        ),
        html.p(f"alerts: {len(alerts)}"),
    )
1
# TODO

Toggle the threshold from 50 to 10 and alerts start appearing; toggle back and they stop. The single long-lived task never restarts and always reads the current number. Verified.

The alternative — usually the better one — is to put the value in the dependency list so a change tears the Effect down and builds a new one with fresh values. Use the ref when restarting is expensive: an open socket, a loop with accumulated state.

Common patterns

Synchronizing with a non-ReactPy object

Third-party objects are imperative: you construct them, call setters, destroy them. State is the wrong home for one (constructing it would re-run each render, and setting it would trigger a render), so keep it in a ref and let Effects drive its lifecycle:

 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
@component
def report(name):
    page_size, set_page_size = use_state("letter")
    # The engine lives in a ref: it has to survive renders, and changing it
    # must not itself trigger a render.
    engine_box: Ref[ReportEngine | None] = use_ref(None)

    def create_engine():
        engine = ReportEngine(name)
        engine_box.current = engine

        def destroy():
            engine.destroy()
            engine_box.current = None

        return destroy

    use_effect(create_engine, dependencies=[name])

    def apply_page_size():
        if engine_box.current is not None:
            engine_box.current.set_page_size(page_size)

    use_effect(apply_page_size, dependencies=[page_size])

    return html.div(
        html.h2(name),
        html.p(f"page size: {page_size}"),
        html.button(
            {
                "onClick": lambda event: set_page_size(
                    "a4" if page_size == "letter" else "letter"
                )
            },
            "Toggle page size",
        ),
    )
1
# TODO

Two Effects, two dependency lists: one creates and destroys the object when name changes, the other pushes page_size into it. Verified against the object's own log: created q3, set_page_size letter, set_page_size a4, destroyed q3 on unmount — one instance, kept in step, cleaned up.

Two Effects in the same component run in declaration order, which is why create_engine is registered first: the second Effect can assume the object exists. That ordering holds within a component; across components it does not. Children's Effects run before their parent's, and siblings run in reverse order — measured ['child effect b', 'child effect a', 'parent effect']. Never write code that needs a parent to have run first.

Subscribing to a stream

The shape is always the same: subscribe inside the Effect, unsubscribe in the clean-up, name the topic in 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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@component
def scroll_log(topic):
    lines, set_lines = use_state([])

    def effect():
        # The clean-up is what keeps the subscriber count at 1 across
        # re-subscriptions. Forget it and every render adds another callback.
        def on_message(message):
            set_lines(lambda previous: [*previous, message])

        unsubscribe = ticker.subscribe(topic, on_message)
        return unsubscribe

    use_effect(effect, dependencies=[topic])

    return html.div(
        html.h2(topic),
        html.ul([html.li({"key": index}, line) for index, line in enumerate(lines)]),
    )


@component
def app():
    topic, set_topic = use_state("news")
    watching, set_watching = use_state(True)

    return html.div(
        html.button(
            {
                "onClick": lambda event: set_topic(
                    "sports" if topic == "news" else "news"
                )
            },
            "Switch topic",
        ),
        html.button(
            {"onClick": lambda event: set_watching(not watching)},
            "Stop watching" if watching else "Watch",
        ),
        html.button({"onClick": lambda event: ticker.publish(topic, "ping")}, "Ping"),
        *([] if not watching else [scroll_log(topic)]),
    )
1
# TODO

Verified through the bus's own log: subscribe news, unsubscribe news, subscribe sports, unsubscribe sports, subscribe news, unsubscribe news across a full switch-and-leave cycle — exactly one subscriber live at any moment.

Note that the callback is defined inside the Effect, so it closes over this Effect's values and the subscription is rebuilt when the dependencies change. Defining it in the component body instead would leave the subscription holding the first version of it.

Fetching data

Fetching belongs in use_async_effect, and cancellation is what makes it safe:

 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
@component
def report_view(name):
    rows, set_rows = use_state("nothing loaded yet")

    async def fetch():
        set_rows(f"loading {name} ...")
        result = await catalog.fetch(name)
        # This line is reached only if nobody cancelled the task. When `name`
        # changes, ReactPy cancels the running task before this line runs, so
        # an answer for the OLD report can never overwrite the new one.
        set_rows(result)

    use_async_effect(fetch, dependencies=[name])

    return html.div(html.h2(name), html.p(rows))


@component
def app():
    name, set_name = use_state("orders")

    return html.div(
        html.button(
            {
                "onClick": lambda event: set_name(
                    "users" if name == "orders" else "orders"
                )
            },
            "Toggle report",
        ),
        report_view(name),
    )
1
# TODO

The example is deliberately lopsided: orders takes 0.8 s and users takes 0.05 s. Press Toggle report while the slow one is in flight and the screen settles on users: 500 rows and stays there. The data source recorded that it computed only the users answer — the orders task was cancelled inside its await and never reached its set_rows. Verified.

That is ReactPy's version of React's ignore flag, and it is stronger: the cancelled run's remaining lines never execute at all. Two consequences:

  • Code after an await may never run. Anything that must happen — closing a handle, releasing a lock — goes in a try / except asyncio.CancelledError pair, and re-raises so asyncio can finish cancelling.
  • An Effect that sets state it depends on loops forever. If a component appears to spin, read what the Effect awaits, what it depends on, and what it sets.

Good alternatives to fetching in an Effect

Manual fetching in an Effect works, and carries the same drawbacks here as in React: no deduplication, no cache shared between components, and a request that cannot start until the component exists. ReactPy has a better option available by default — fetch during a parent's render, or at startup, and pass the result down as a prop. The child then renders with data instead of loading it. Keep the Effect for data that genuinely depends on something only the connection knows, such as use_location.

Sending analytics

Report a page view when the page changes, not when the component happens to render:

 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
@component
def page_view(path):
    draft, set_draft = use_state("")

    def effect():
        # Depends on `path` only: editing the field re-renders the component
        # but does not report another visit.
        analytics.log_visit(path)

    use_effect(effect, dependencies=[path])

    return html.div(
        html.h2(path),
        html.p(f"total reports sent: {len(analytics.sent)}"),
        html.input(
            {
                "placeholder": "notes",
                "onChange": lambda event: set_draft(event["target"]["value"]),
            }
        ),
    )


@component
def app():
    path, set_path = use_state("/home")

    return html.div(
        html.button(
            {
                "onClick": lambda event: set_path(
                    "/about" if path == "/home" else "/home"
                )
            },
            "Navigate",
        ),
        page_view(path),
    )
1
# TODO

The Effect depends on path only. Type into the field and the report count stays put; press Navigate and it climbs by one. Verified: two typing re-renders produced zero reports, two navigations produced two.

Not an Effect: initializing the application

Logic that should run once for the whole process does not belong in a component at all — a component's Effect runs once per connection, and a server serves many:

# module level: runs when the module is imported, once
configure_logging()
POOL = create_pool()

Put it in the ASGI app's startup, or in the module that builds the ReactPy executor. Reach for a dependencies=[] Effect only when the work is genuinely per-connection. Never guard it with my_component.started = True: the function object is module-scope, so the first visitor flips the flag for everyone (VDOM Mutations).

Not an Effect: buying a product

An Effect is not the place for something that must happen exactly once per user action, because you cannot promise how many times an Effect runs — dependencies change, components unmount, processes restart. Charging a card belongs in an event handler, where a click is the only trigger:

def handle_buy(event):
    set_status("buying...")
    order = place_order(sku)
    set_status(f"ordered {order.id}")

Deep dive: each render has its own Effects

An Effect belongs to the render that created it. Its closure holds that render's props and state, and it never sees a later one. The dependency list chooses which render's Effect survives; it does not update the one that did:

 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 audit_log(room, message):
    entries, set_entries = use_state([])

    def effect():
        # This closure sees the `message` from the render that CREATED this
        # Effect -- not the newest one. The dependency list says when to make
        # a new Effect; the values it reads are frozen from that render.
        set_entries(lambda previous: [*previous, f"{room} <- {message}"])

    use_effect(effect, dependencies=[room])

    return html.ul(
        [html.li({"key": index}, line) for index, line in enumerate(entries)]
    )


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

    return html.div(
        html.p(f"room={room} message={message!r}"),
        html.button({"onClick": lambda event: set_message(message + "!")}, "Type !"),
        html.button(
            {"onClick": lambda event: set_room("dev" if room == "lobby" else "lobby")},
            "Switch room",
        ),
        audit_log(room, message),
    )
1
# TODO

Press Type ! twice, then Switch room. The log gains lobby <- hello from the mount, and then dev <- hello!! — the Effect that ran for dev was created by the render whose message was already 'hello!!'. Verified. No entry ever records an intermediate message, because only the renders whose dependencies changed created an Effect.

This is why "my Effect reads an old value" has exactly two correct answers: add the value to the dependency list, or read it through a ref.

There is no double-invoke in development

React remounts every component twice under Strict Mode to expose Effects that are not restart-safe. ReactPy has no Strict Mode and never calls your component twice — verified: a component that increments a counter in its own body counted exactly one render per render.

Two consequences:

  • Nothing will find your unclean Effects for you. The restart-safety check React gives away for free is one you have to run yourself: render the component, unmount it, mount it again, and assert the external system is back where it started. A reactpy.core.layout.Layout in a pytest makes that a ten-line test.
  • Do not add artificial guards to compensate. if already_ran.current: return inside an Effect is evidence the dependency list is wrong, not a fix. Write the clean-up so a restart is free and the question stops mattering.

Recap

  • An Effect is a side effect caused by rendering; an event handler is one caused by a user action. Prefer the handler.
  • Effects are scheduled after the render completes, in the connection's own task. They do not block the render, and the browser may not have the update yet.
  • They run on the server: no DOM, no window, no element to focus.
  • use_effect takes a plain def; use_async_effect takes a zero-argument async def. One mismatch fails loudly, the other (the old async def effect(stop) shape) fails silently.
  • dependencies has four settings. Omitted means inferred from the closure, not "every render" — and the comparison is ==, not identity.
  • Return a clean-up function. It runs before the Effect's next run and on unmount. Skipping it leaks, and a leak in a long-lived server process leaks forever.
  • A long-lived Effect sees a frozen snapshot; read current values from a ref, or restart the Effect by depending on them.
Authors: Archmonger