Skip to content

Separating Events from Effects

Overview

Event handlers and Effects run at different times and for different reasons. Most Effect bugs are not Effect bugs — they are interaction logic that was put in the wrong place. Move it back and the Effect disappears.

You will learn

  • Why interaction logic belongs in a handler, not an Effect
  • Why handler logic is not reactive — and why that is a feature
  • How ReactPy picks which render's handler actually runs
  • What use_callback does in ReactPy, and what it does not buy you
  • How to read the newest value from a callback you cannot re-create

Choosing between event handlers and Effects

The question is never "which hook?" but "when should this logic run?"

Event handler Effect
Runs when the interaction happens after a render, whenever a dependency changed
Re-runs when only if the user repeats the interaction any dependency changes — including one you forgot to list
Right for submit, click, input, drag, "do this now" subscribing, polling, syncing to a server

Both cost one render per click in the examples below — the difference is not performance, it is what triggers the work. An Effect that sets state it also lists as a dependency re-triggers itself; measured, that loop reached 86 renders before it was stopped. A handler has no such feedback path, which is most of why interaction logic belongs there.

If the logic should run when the user does the thing, it goes in the handler. If it should run whenever a value is a certain way, it goes in an Effect.

Logic in an Effect, driven by click-only state

Here the shipping quote is computed by an Effect watching a flag that only a click sets:

 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
def calculate_shipping(city, country):
    """Plain function: takes what it needs, returns the result."""
    if not city or not country:
        return ""
    return f"{city}, {country} — ${15 + 5}"


@component
def shipping_form():
    city, set_city = use_state("")
    country, set_country = use_state("")
    # state that exists purely to make the Effect run
    quoted, set_quoted = use_state(False)
    shipping_info, set_shipping_info = use_state("")
    clicks, set_clicks = use_state(0)

    async def submit(event):
        set_clicks(clicks + 1)
        set_quoted(True)

    # the "intent" is buried in an Effect watching a flag a click merely sets
    def recalculate():
        if quoted:
            result = calculate_shipping(city, country)
            set_shipping_info(result)
            if result:
                print(f"quoted {result}")

    use_effect(recalculate, dependencies=[quoted])

    async def handle_city_change(e):
        set_city(await e.target.value)

    async def handle_country_change(e):
        set_country(await e.target.value)

    return html.div(
        html.label(
            "City: ",
            html.input({"value": city, "onChange": handle_city_change}),
        ),
        html.label(
            "Country: ",
            html.input(
                {
                    "value": country,
                    "onChange": handle_country_change,
                }
            ),
        ),
        html.button({"onClick": submit}, "Get quote"),
        html.p(shipping_info or "—"),
        f"Clicks: {clicks}",
    )


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

This is where the ReactJS version of this example stops matching reality. Verified behaviour: type a city and country, then press Get quote three times.

  • Click 1 logs quoted Portland, US — $20.
  • Clicks 2 and 3 increment the counter and log nothing at all.

set_quoted(True) sets the same value every time, so ReactPy sees no change, and the dependency list says nothing re-synchronized. The user gets a button that works once. Nothing about this is obvious from the code — the Effect looks correct.

The same logic in the handler

 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
def calculate_shipping(city, country):
    """Plain function: takes what it needs, returns the result."""
    if not city or not country:
        return ""
    return f"{city}, {country} — ${15 + 5}"


@component
def shipping_form():
    city, set_city = use_state("")
    country, set_country = use_state("")
    shipping_info, set_shipping_info = use_state("")
    clicks, set_clicks = use_state(0)

    async def submit(event):
        set_clicks(clicks + 1)
        result = calculate_shipping(city, country)
        set_shipping_info(result)
        if result:
            print(f"quoted {result}")

    async def handle_city_change(e):
        set_city(await e.target.value)

    async def handle_country_change(e):
        set_country(await e.target.value)

    return html.div(
        html.label(
            "City: ",
            html.input({"value": city, "onChange": handle_city_change}),
        ),
        html.label(
            "Country: ",
            html.input(
                {
                    "value": country,
                    "onChange": handle_country_change,
                }
            ),
        ),
        html.button({"onClick": submit}, "Get quote"),
        html.p(shipping_info or "—"),
        f"Clicks: {clicks}",
    )


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

No trigger state, no Effect, no use_effect import. Verified: every click logs quoted Portland, US — $20, at the same one-render-per-click cost as a plain button.

The general rule: if the only thing that sets a piece of state is an event handler, and the only thing that reads it is an Effect, delete both and call the function directly.

When an Effect really is the right home

Keep the Effect when the logic must also run for reasons other than the interaction: the value changed while the user was idle, the component just mounted, another component changed something. "The user pressed Save" is a handler; "the document is dirty and should be persisted" is an Effect.

Reactive values and reactive logic

Every value in the component body belongs to one render. Logic placed in the component body or in an Effect re-runs when those values change. Logic inside a handler does not — it runs when called, on whatever render produced it.

Logic inside event handlers is not reactive

async def submit(event):
    print(f"submitting {city}, {country}")   # reads one render's values
    send(city, country)

Nothing about this code will re-run when city changes. That is not a defect: a click should do the thing once, using the values the user saw when they clicked. Making handler logic "reactive" is what produces double-submits and the click-once bug above.

Logic inside Effects is reactive

def reconnect():
    open_socket(room)

use_effect(reconnect, dependencies=[room])

This one re-runs on its own whenever room changes, including changes you did not expect. That is the whole point of an Effect — and the whole reason logic you don't want repeated does not belong there.

Extracting non-reactive logic out of Effects

Once intent lives in the handler, the shared part is usually an ordinary function. Give it its inputs as parameters and both sides can use it:

def calculate_shipping(city, country):
    if not city or not country:
        return ""
    return f"{city}, {country} — ${15 + 5}"


@component
def shipping_form():
    ...

    async def submit(event):
        set_shipping_info(calculate_shipping(city, country))

No hook needed. A function that only reads values and returns something does not need to be a hook, and making it one costs a render.

ReactPy has no useEffectEvent, no useOptimistic, and no useTransition — verified against reactpy at main. That is not a gap to route around with an Effect: the handler-is-not-reactive property that Effect Events exist to give you in React is what you already have here for free.

Which handler actually runs

ReactPy keeps one handler per target, registered when the component renders (core/layout.py, _event_handlers), and looks it up at dispatch time — when the event is processed, not when it arrived.

Two things follow, both measured.

A normal click sees current values. Click after click with the connection settling between them, and the handler always reads the latest state:

added item, discount=0
added item, discount=10
added item, discount=20

Events that arrive before the next render all see the same snapshot. Deliver two clicks without a render in between and both run the same closure:

 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 basket():
    items, set_items = use_state([])
    discount, set_discount = use_state(0)

    async def add_naive(event):
        # reads `items` and `discount` from whichever render created this handler
        print(f"naive: {len(items)} items, discount={discount}")
        set_items([*items, f"item-{len(items) + 1}@{discount}"])

    async def add_safe(event):
        # the updater receives the *current* state at the moment it is applied
        def append(current):
            print(f"safe: {len(current)} items, discount={discount}")
            return [*current, f"item-{len(current) + 1}@{discount}"]

        set_items(append)

    async def handle_discount_click(e):
        set_discount(discount + 10)

    return html.div(
        html.button({"onClick": add_naive}, "Add (naive)"),
        html.button({"onClick": add_safe}, "Add (safe)"),
        html.button({"onClick": handle_discount_click}, f"Coupon {discount}%"),
        html.p(", ".join(items) or "empty basket"),
    )


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

Verified with two clicks delivered back-to-back:

Handler log Final list
Add (naive) 0 items twice item-1@0 — the second click is gone
Add (safe) 0 items, then 1 items item-1@0, item-2@0

This is not a hypothetical. On a real connection, events are queued per target (core/layout.py, _process_event_queue) and a double-click, a fast key repeat, or any latency between the browser and a busy server produces exactly this. The naive set_items(items + [...]) reads a snapshot; the updater set_items(lambda current: ...) is applied against the state at the moment it lands.

So the handler-staleness story in ReactPy is narrower than in React, and it is worth being precise:

  • You generally do not need use_callback to "get fresh values" in a handler — the handler that runs is the current one.
  • You do need the functional updater form whenever an update depends on the current state and two events can arrive in the same round trip.

Handlers which await

Every handler is a coroutine, and it may set state after an await:

async def submit(event):
    set_status("saving")
    result = await save(document)
    set_status("saved")          # works — the render happens after the await

Verified: the page shows status=saving immediately, then status=saved once the await resolves — two renders, both after the click.

But if the component unmounts during the await, the update is discarded and the only trace is a debug line — Did not render component with model state ID ... - component already unmounted. Nothing errors, nothing warns, and the user who navigated away is unaffected. Do not treat "the setter did not raise" as evidence the write landed; if it matters, assert it in a test that keeps the component mounted.

Keeping a callback's identity stable

use_callback exists and memoizes through use_memo, so it shares the dependency semantics described on Lifecycle of Reactive Effects — strictly_equal, not id.

 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 toolbar():
    count, set_count = use_state(0)
    theme, set_theme = use_state("light")
    previous = use_ref(object())

    async def stable_handler(event):
        set_count(count + 1)

    # inferred dependencies: `count` is the only free variable
    on_click = use_callback(stable_handler)

    reused = previous.current is on_click
    previous.current = on_click

    async def handle_theme_click(e):
        set_theme("dark" if theme == "light" else "light")

    return html.div(
        html.button({"onClick": on_click}, f"Count {count}"),
        html.button(
            {"onClick": handle_theme_click},
            f"Theme {theme}",
        ),
        html.p("handler reused on this render" if reused else "handler recreated"),
    )


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

Verified: pressing Theme (not a dependency) reports handler reused on this render; pressing Count (a dependency) reports handler recreated.

Be clear about what that buys you. ReactPy has no render bailout: there is no memo (absent from both reactpy and reactpy.reactjs), and a child re-renders whenever its parent does. Measured — a child whose props and handler never changed ran its body 4 times across one mount and three parent re-renders. So a stable callback does not make anything re-render less. What it does is keep a value out of an Effect's or use_memo's dependency list without re-triggering it.

There is no useEvent, so the nearest equivalent to React's Effect Event — a stable callable that always reads the newest values — is a ref updated during 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
39
@component
def checkout():
    coupon, set_coupon = use_state("")
    total, set_total = use_state(0)
    coupon_ref = use_ref("")
    # updated during render, so any callback reads the value as of the click
    coupon_ref.current = coupon

    # pinned to nothing: the identity never changes, yet it reads `coupon_ref`
    async def apply_coupon_handler(event):
        print(f"applying coupon={coupon_ref.current!r}")

    apply_coupon = use_callback(apply_coupon_handler, dependencies=[])

    async def handle_coupon_change(e):
        # The await comes first: `.upper()` runs on the string which comes back,
        # not on the proxy. Without the parens you would call `.upper()` on the
        # proxy itself and silently get the un-uppercased value.
        set_coupon((await e.target.value).upper())

    async def handle_total_click(e):
        set_total(total + 10)

    return html.div(
        html.input(
            {
                "value": coupon,
                "onChange": handle_coupon_change,
            }
        ),
        html.button({"onClick": apply_coupon}, "Apply coupon"),
        html.button({"onClick": handle_total_click}, f"Total {total}"),
        html.p(f"page shows coupon={coupon!r} — compare with the console"),
    )


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

Verified: Apply coupon logs the value currently in the box even though the callback is pinned to dependencies=[] and is never re-created.

A callback pinned to a local function never updates

Same trap as Effects, and it is worse here because the callback is supposed to be stable. dependencies=[describe], where describe is a local def, compares code-equal forever:

 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
@component
def search():
    query, set_query = use_state("")
    runs, set_runs = use_state(0)

    def describe():
        return f"query={query!r}"

    async def run(event):
        # `describe` is re-created every render but compares code-equal, so this
        # callback is still the one from the very first render. The functional
        # updater keeps the counter honest; only `describe()` is frozen.
        print(f"handler sees {describe()}")
        set_runs(lambda current: current + 1)

    on_click = use_callback(run, dependencies=[describe])

    async def handle_query_change(e):
        set_query(await e.target.value)

    return html.div(
        html.input({"value": query, "onChange": handle_query_change}),
        html.button({"onClick": on_click}, f"Run ({runs})"),
        html.p(f"page shows query={query!r} — compare with the console"),
    )


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

Verified: type shoes, press Run, type shoes2, press Run again. The page shows the new query both times; the console says handler sees query='' both times. The counter keeps counting (Run (2)), which is what makes this so easy to miss — the callback looks alive while reading a snapshot from mount.

Depend on the scalar values the callback actually reads, and never on a function defined inside the component.

Recap

  • Ask when the logic should run. Interaction → handler. Synchronization → Effect.
  • State that only a handler sets and only an Effect reads is a smell: call the function from the handler and delete both. Verified symptom: the button works once.
  • Handler logic is deliberately not reactive; that is why you do not need React's Effect Events here.
  • ReactPy stores one handler per target and looks it up at dispatch, so a normal click sees current values — but events delivered before the next render share one snapshot. Use the functional updater for read-modify-write.
  • An async handler may set state after await; after unmount that update is silently dropped.
  • use_callback pins identity, not freshness, and identity buys you nothing for render cost in ReactPy. Reach for a render-updated ref when you need both.
  • Never list a locally defined function as a dependency of a callback or Effect — it compares code-equal and freezes the closure.
Authors: Archmonger