Skip to content

Creating VDOM Event Handlers

Overview

An event handler in ReactPy is any callable 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. 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 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).
def save(event):
    print("saved", 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
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_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.

None is not a handler. The classic {"onClick": handle_click()} — calling instead of passing — yields {"attributes": {"onClick": None}}. The button renders, looks dead, and logs nothing on either side. If a control "does nothing", check that you passed the function.

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 callables, the shape you want most of the time

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

def handle_click(event):
    print(event["target"]["value"])


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

Sync functions, coroutine functions, and lambdas all work. Name the parameter whatever you like — the value is positional, and its type is reactpy.types.Event, a dict subclass with attribute access into nested dicts, so event["target"]["value"] and event.target.value are the same read. See Responding to Events for the payload surface and Protocol Structure for what the client actually serializes.

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, usually with a lambda:

html.button({"onClick": lambda event: delete(item.id)}, "Delete")

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
# A plain callable gets the browser's argument list UNPACKED.
def plain_handler(event):
    print(event["target"]["value"])  # the event dict


# An EventHandler gets the LIST ITSELF.
async def list_handler(data):
    print(data[0]["target"]["value"])  # index first


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

# Converting one to the other without changing the body breaks it:
# TypeError: list indices must be integers or slices, not str
1
# TODO

The failure when you get it backwards is TypeError: list indices must be integers or slices, not str inside the handler — and the layout swallows it into a server-side Failed to execute event handler line. Nothing appears in the browser console, so the UI merely stops responding. Prefer the plain-callable form unless you specifically need to hand-build a handler; @event gives you the same options with the friendlier argument shape.

Setting the four options

Four 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
debounce int ms rate-limits server→client value updates on input elements
throttle int ms rate-limits client→server outgoing events

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, 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.
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 (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 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 still fires — it is a perfectly good callable — 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:
    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. functools.partial is fine — the inspector unwraps .func — and so is a functools.wraps decorator, via __wrapped__.

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' or 'target' 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. Did you pass the function or call it? {"onClick": handler()} gives None, which becomes an inert attribute.
  2. Is the key a real DOM event name in camelCase? on_click and onclick never fire.
  3. Does the handler take exactly one parameter? Two required parameters means a server-side TypeError and a dead button.
  4. Is it a bound method? Then preventDefault/stopPropagation/debounce are not detected — use @event.
  5. 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, string under onXxx → inlineJavaScript, everything else → attributes.
  • None, True, and dict handlers all fail silently as attributes.
  • A plain callable receives the argument list unpacked (so, the event dict); an EventHandler receives the list.
  • Set options with @event(prevent_default=..., stop_propagation=..., debounce=..., throttle=...); the bytecode detector is a convenience with real limits, notably bound methods.
  • debounce limits server→client value updates; throttle limits client→server events. Invalid values are dropped with a console warning, not an error.
  • Handler errors surface in the server log only.
Authors: Archmonger