Skip to content

Creating VDOM Event Handlers

Overview

An event handler in ReactPy is a coroutine you put in an element's attribute dict. ReactPy inspects the value, not the name, decides what it is, and files it under one of three keys in the model — refusing it outright if it cannot be awaited. Most of the surprises on this page come from that one decision point: what counts as a handler, what counts as JavaScript, and what happens to the options you thought you set.

You will learn

  • The three buckets an attribute value lands in, and the rule that sorts them
  • Why a handler must be async def, and what that rules out
  • Why preventDefault() sometimes reaches the browser and sometimes does not
  • The difference in arguments between a plain callable and an EventHandler
  • How to set debounce and throttle, and what values are rejected

The three buckets

When you call html.button({...}, ...), ReactPy walks the attribute dict once and routes every value by kind — callable first, then string-under-onXxx, then everything else (core/vdom.py, separate_attributes_handlers_and_inline_javascript):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# ReactPy sorts each value in the attribute dict into one of three buckets
# by *kind*, not by name (core/vdom.py, separate_attributes_handlers_and_inline_javascript).
async def save(event):
    print("saved", await event.target.value)


element = html.button(
    {
        "id": "save",  # str, not onXxx -> attribute
        "onClick": save,  # callable -> event handler
        "onHover": "console.log('hover')",  # str under onXxx -> inline JS
        "data-row": "3",  # str, not onXxx -> attribute
    },
    "Save",
)

print(sorted(element))
# ['attributes', 'children', 'eventHandlers', 'inlineJavaScript', 'tagName']
print(sorted(element["attributes"]))  # ['data-row', 'id']
print(sorted(element["eventHandlers"]))  # ['onClick']
print(sorted(element["inlineJavaScript"]))  # ['onHover']
1
# TODO

The rules, exactly:

Value Lands in Note
Anything callable eventHandlers under any key name; anything callable which is not a coroutine raises
A BaseEventHandler eventHandlers stored as-is, untouched
A str under a key matching ^on[A-Z]\w+ inlineJavaScript eval'd in the page
A reactpy.types.InlineJavaScript string inlineJavaScript under any key
Everything else attributes forwarded verbatim

Three of those rows have consequences worth spelling out.

callable wins over the key name. {"notAnHandler": my_async_function} registers a real listener whose target ends in :notAnHandler. It will never fire, because no DOM event is named that, but it is registered. Conversely {"onClick": True} is not a handler — a bool is not callable, so it becomes an attribute and no listener exists. Neither mistake produces an error. What does raise is a callable ReactPy cannot await: the sort step builds an EventHandler for every callable value, and that is where the coroutine check lives.

Calling instead of passing gives you something that is not a handler. A handler is a coroutine, so {"onClick": handle_click()} hands over a coroutine object, which is not callable and so falls through to the attributes: the button renders, looks dead, and the failure surfaces later as TypeError: Type is not JSON serializable: coroutine when the model is encoded for the wire — plus a RuntimeWarning: coroutine ... was never awaited in the log. Calling something that was never a handler ({"onClick": print("hi")}) is the None case, and that one really is silent on both sides. Either way: pass the function, do not call it. The checklist in What to check when a control does nothing covers all three shapes.

A dict is never a handler. The React-style {"function": fn, "preventDefault": True} becomes an ordinary attribute; no listener is attached. With REACTPY_DEBUG on it fails loudly instead — TypeError: Object of type function is not JSON serializable — which is the better outcome. Build handlers with @event or EventHandler, never with a dict.

Plain coroutines, the shape you want most of the time

Pass a coroutine and ReactPy wraps it. The browser sends a list of arguments; a plain coroutine receives it unpacked positionally (core/events.py, to_event_handler_function), which for a DOM event means one argument:

async def handle_click(event):
    print(await event.target.value)


html.button({"onClick": handle_click}, "Click me")

The parameter name is yours to choose — the value is positional. What is not optional is async def: EventHandler.__init__ and to_event_handler_function both run the value through check_is_async_handler, and anything that is not awaitable raises there. A coroutine function, a callable object whose __call__ is a coroutine, and a functools.partial or functools.wraps wrapper around either all pass. A plain def and a lambda do not — the latter has no syntax for await, which is why the old inline-handler idiom is gone.

The value handed over is a reactpy.types.Event: not a dict, but a live proxy onto the browser object, where await event.target.value is the read and the property chain matches JavaScript's. See Responding to Events for the payload surface and Protocol Structure for what crosses the wire.

Declare exactly one parameter

The list is unpacked onto your signature, so a handler declaring two required parameters fails at the moment of the click with TypeError: ... missing 1 required positional argument, logged server-side only — the browser stays silent and the button just stops working. Extra context has to be bound in Python, and since a lambda is no longer available that means a named handler or a closure:

def delete_handler(item_id):
    async def handle_click(event):
        await delete(item_id)

    return handle_click


html.button({"onClick": delete_handler(item.id)}, "Delete")

functools.partial does the same job, with one catch: the client still hands over the event, so the underlying function must leave exactly one parameter free — partial(delete, item.id) over async def delete(item_id, event), not over async def delete(item_id). Binding the leading argument also moves the event off the first parameter, which is what the bytecode detector matches, so see Be discovered: bytecode inspection below: a preventDefault() in that handler is not detected, and @event(prevent_default=True) is the fix.

EventHandler: the pre-built form, and its different argument

EventHandler is what the @event decorator returns, and you can construct it directly. Two things change when you do.

First, ReactPy stores an EventHandler as-is rather than wrapping it, so the same object can serve every element that uses it. It does not follow that they share a target. The target is derived per element: unless the handler carries an explicit target, core/layout.py builds target = f"{key_path}:{event}", and @event leaves target as None. Verified with one @event handler on two buttons: the targets came out /0/0:onClick and /0/1:onClick — one function object, two targets.

Do not try to force the sharing by setting target= yourself. Two elements registered under the same explicit target overwrite each other in the layout's handler table, and the unmount path then tries to del that one key twice — verified, the second deletion raises KeyError from Layout._unmount_model_states.

Second, and this is the trap: an EventHandler receives the list itself, not the unpacked arguments.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# A plain coroutine gets the browser's argument list UNPACKED.
async def plain_handler(event):
    print(await event.target.value)  # the event itself


# An EventHandler gets the LIST ITSELF: an EventArguments, a list subclass.
async def list_handler(data):
    print(await data[0].target.value)  # index first, then read as usual


html.button({"onClick": plain_handler}, "a")
html.button({"onClick": EventHandler(list_handler, prevent_default=True)}, "b")

# Hand the list to a body written for the event and the first read fails:
# AttributeError: 'EventArguments' object has no attribute 'target'
# Hand the event to a body written for the list and subscripting it fails:
# TypeError: 'Event' object is not subscriptable
1
# TODO

The failure when you get it backwards differs by direction, and the layout swallows either one into a server-side Failed to execute event handler line — nothing appears in the browser console, so the UI merely stops responding. Hand the list to a handler written for the event and the first property read raises AttributeError: 'EventArguments' object has no attribute 'target' — the list is an EventArguments, a list subclass, not the event. Hand the unpacked event to a handler written for the list and you get TypeError: 'Event' object is not subscriptable. Prefer the plain-coroutine form unless you specifically need to hand-build a handler; @event gives you the same options with the friendlier argument shape.

Setting the five options

Five behaviours run in the browser before an event is sent. They are properties on the handler object, never attributes on the element:

Option Type Effect
prevent_default bool calls preventDefault() in the page
stop_propagation bool calls stopPropagation() in the page
stop_immediate_propagation bool also calls stopImmediatePropagation(), blocking sibling listeners
debounce int ms coalesces outgoing events on a trailing edge, so the server sees one event after the user pauses — applied only to <input>, <select>, and <textarea>
throttle int ms rate-limits outgoing events on a leading edge, on any element

There are two ways to set them, and the difference matters.

Say it: @event

from reactpy import event


@event(
    prevent_default=True,
    stop_propagation=True,
    stop_immediate_propagation=True,
    debounce=250,
    throttle=100,
)
async def handle_key_down(event_data): ...


html.input({"onKeyDown": handle_key_down})

This is explicit and it always works. Reach for it by default.

Be discovered: bytecode inspection

ReactPy also reads your function's bytecode looking for three specific statements, so you can write them inline instead (core/_event_inspect.py, inspect_event_handler):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Same behaviour, discovered by reading your bytecode instead of a decorator.
async def submit(event):
    event.preventDefault()
    event.stopPropagation()
    event.debounce = 250


handler = html.form({"onSubmit": submit}, "x")["eventHandlers"]["onSubmit"]
print(handler.prevent_default)  # True
print(handler.stop_propagation)  # True
print(handler.debounce)  # 250
1
# TODO

This is a source-parsing hack, not a runtime mechanism, and it has limits you should know before relying on it. Detection requires all of the following:

  • The call is written literally in the handler's own body. event.preventDefault() inside a helper the handler calls is invisible to the inspector — it does not follow calls.
  • The first parameter is the event. The inspector reads co_varnames[0] and matches against it.
  • For debounce, the assigned value must be resolvable at inspect time: an integer literal, a compile-time-folded expression like 100 * 5, a numeric default on a parameter (async def h(event, ms=350)), or a numeric closure cell. It cannot resolve a parameter with no default, a module-level global, or an arbitrary expression — all three inspect to None. And it stops scanning at the first assignment it can settle — resolved or not — so with two assignments to event.debounce the result depends on which one the bytecode reaches first. Verified: if event: event.debounce = 200 / else: event.debounce = len(x) inspects to 200, while the same branches written in the other order inspect to None. Write one assignment and the question does not arise; with more than one, check the value you get.

The second rule produces the sharpest bug, so it deserves its own call-out:

Bound methods are not inspected correctly

For async def on_submit(self, event), the first parameter is self, so the inspector never matches the event variable and reports prevent_default=False. The handler is a coroutine, so it passes the check and still fires — but your event.preventDefault() call is silently discarded:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# A bound method is a fine handler -- it fires. What is lost is detection of
# the event.preventDefault() inside it: the bytecode inspector reads the
# *first* parameter as the event, and for a method that is `self`.
class SearchForm:
    async def on_submit(self, event):
        event.preventDefault()


plain = html.form({"onSubmit": SearchForm().on_submit})["eventHandlers"]["onSubmit"]
print(plain.prevent_default)  # False -- your call was never seen


# Say it explicitly with @event and it works.
@event(prevent_default=True)
async def submit(event_data):
    print("submitted")


explicit = html.form({"onSubmit": submit})["eventHandlers"]["onSubmit"]
print(explicit.prevent_default)  # True
1
# TODO

Attach a plain function defined inside the component instead, or pass @event(prevent_default=True) explicitly over the method. A functools.wraps decorator is fine — the inspector follows __wrapped__ to the function you wrote, first parameter and all. A functools.partial is unwrappable too, but only half-fixed: once the leading argument is bound, the first parameter the inspector reads is yours, not the event's, so the same silent False appears. Same escape hatch: declare the option with @event.

An inline-JavaScript handler cannot be inspected at all

Detection runs on Python bytecode. {"onClick": "this.innerText = 'x'"} has none, so there is no preventDefault equivalent in the string form. Call it yourself in the JavaScript: "event.preventDefault(); doThing()".

Inline JavaScript

A str under an onXxx key is stored under inlineJavaScript and eval'd in the page; if it evaluates to a function, that function is called with the event:

html.button({"onClick": '(event) => (event.target.innerText = "Thank you!")'}, "Click")
html.button({"onClick": "this.innerText = 'Thank you!'"}, "Click")

This is the way to touch the DOM from ReactPy, and the reason it exists is that the server has no node to hold (Manipulating the DOM with Refs). Keep each snippet to a single call, and never interpolate anything you did not write:

Trusted strings only

The string runs with your application's privileges, in the page. f"this.innerText = '{user_input}'" is an injection point. Build strings from constants, or use a Python handler and pass data through state.

Note also that the pattern is ^on[A-Z]\w+ — a lowercase {"onclick": "..."} matches nothing and becomes a plain attribute that never fires.

Merging handlers

reactpy.core.events.merge_event_handlers combines several handlers into one that runs them concurrently in a task group. It is public API but nothing in the installed package calls it — verified by grepping the whole reactpy package, which finds only its own definition — so you will rarely have a reason to call it. Its failure mode is still worth knowing, because it is a ValueError at build time:

merge_event_handlers([a, b])
# ValueError: Cannot merge handlers - 'stop_propagation', 'prevent_default',
# 'debounce', 'throttle', 'target', or 'stop_immediate_propagation' mismatch.

Handlers merge only if every option matches. An empty list raises ValueError: No event handlers to merge.

What to check when a control does nothing

Every failure mode on this page is silent in the browser, so work through them in this order:

  1. Is it declared async def? A def, a lambda, or a state setter raises while the component renders, and the traceback names the function.
  2. Did you pass the function or call it? A handler is a coroutine, so {"onClick": handler()} hands over a coroutine object — not callable, so it falls through to the attributes and dies when the model is encoded: TypeError: Type is not JSON serializable: coroutine from the send path. A handler which returns something instead (None from a helper) ships as an inert attribute and never fires. Either way: pass the function, do not call it.
  3. Is the key spelled the way the DOM spells its event, with a leading on and a capital letter? ReactPy passes the key straight through, so a on_click registers a listener for an event named _click, which nothing ever dispatches. Copy the DOM's camelCase spelling (onClick, onDoubleClick, onKeyDown) rather than guessing.
  4. Does the handler take exactly one parameter? Two required parameters means a server-side TypeError and a dead button.
  5. Is it a bound method? Then preventDefault/stopPropagation/debounce are not detected — use @event.
  6. Read the server log. Failed to execute event handler is where swallowed handler errors go; the browser console will show nothing.

Summary

  • Values are sorted by kind, not name: callable → eventHandlers (and raises there unless it can be awaited), string under onXxx → inlineJavaScript, everything else → attributes.
  • None, True, and dict handlers all fail silently as attributes.
  • A handler is a coroutine — a def or a lambda is refused with a TypeError when the element is built, not when it is clicked.
  • A plain coroutine receives the argument list unpacked (so, the event proxy); an EventHandler receives the list.
  • Set options with @event(prevent_default=..., stop_propagation=..., stop_immediate_propagation=..., debounce=..., throttle=...); the bytecode detector is a convenience with real limits, notably bound methods.
  • Both debounce and throttle rate-limit client→server traffic: debounce coalesces outgoing events on a trailing edge and only on user-input elements, throttle fires on a leading edge on any element. The server's echo of a new value is reconciled by _reactpy_ack_seq, not by debounce. Invalid values are dropped with a console warning, not an error.
  • Handler errors surface in the server log only.
Authors: Archmonger
Reactive Python and affiliates.