Skip to content

Responding to Events

Overview

ReactPy lets you add event handlers to your VDOM. Event handlers are your own functions that will be triggered in response to interactions like clicking, hovering, focusing form inputs, and so on.

You will learn

  • Different ways to write an event handler
  • How to pass event handling logic from a parent component
  • How events propagate and how to stop them
  • What the event object actually contains

Adding event handlers

To add an event handler, you first define a function and then pass it as a prop to the appropriate tag. For example, here is a button that doesn't do anything yet:

1
2
3
@component
def button():
    return html.button("I don't do anything")
1
# TODO

You can make it show a message when a user clicks by following these three steps:

  1. Declare a function called handle_click inside your def button() component.
  2. Implement the logic inside that function (use print to show the message).
  3. Add "onClick": handle_click to the html.button attributes dictionary.
1
2
3
4
5
6
7
8
9
from reactpy import component, html


@component
def button():
    async def handle_click(event):
        print("You clicked me!")

    return html.button({"onClick": handle_click}, "Click me")
1
2
3
button {
    margin-right: 10px;
}
1
# TODO

You defined the handle_click function and then passed it as a prop to html.button. handle_click is an event handler. Event handler functions:

  • Are usually defined inside your components.
  • Have names that start with handle, followed by the name of the event.

By convention, it is common to name event handlers as handle followed by the event name. You'll often see "onKeyDown": handle_key_down, "onMouseEnter": handle_mouse_enter, and so on.

The handler key is camelCase, and it lives in the attributes dict

This is the single most common mistake when moving from ReactJS examples. The key is the DOM event name in camelCase — "onClick", "onChange", "onSubmit" — and it goes inside the attributes dictionary, not as a keyword argument:

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

Writing html.button({"on_click": handle_click}, ...) does not raise. ReactPy ships it to the browser verbatim as a handler for an event called _click — the client registers a listener for the key with the leading on stripped — and no browser ever fires one. The button looks fine and simply never responds. Only the names you define (variables, components, component props) are snake_case.

Note what does and does not decide "this is a handler". In core/vdom.py, separate_attributes_handlers_and_inline_javascript the test is callable(v) on the value, checked before the key is ever looked at: a callable under "on_click", or even under "foo", lands in eventHandlers. That sort step is also where the coroutine requirement is enforced, because it builds an EventHandler for the value — so any callable which cannot be awaited raises TypeError at element-build time, whatever key it sits under. The key pattern ^on[A-Z]\w+ (core/vdom.py, EVENT_ATTRIBUTE_PATTERN) is used only for the string case below — it decides whether a string becomes inlineJavaScript or a plain attribute. So {"onClick": "alert(1)"} runs as JavaScript, while {"onclick": "alert(1)"} and {"on_click": "alert(1)"} become inert attributes.

An event handler has to be declared with async def, so it cannot be written inline:

async def handle_click(event):
    print("You clicked me!")


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

A handler must be a coroutine — so a lambda never works

The first rule of an event handler is that it is declared async def. ReactPy checks it the moment the element is built, and refuses anything else:

TypeError: handle_click cannot be used as an event handler because it is not a coroutine.
Event handlers must be declared with 'async def':

    async def on_click(event):
        ...

This is not ceremony. Reading the object an event fired from means waiting on the browser, and only a coroutine can await that round trip without stalling the layout — a blocking read would have to run off-loop, which is the one thread where set_state() fails with no running event loop.

The consequence for a lambda is direct: a lambda can never be a coroutine, so {"onClick": lambda event: ...} raises. Name the handler instead. Passing a state setter directly ({"onClick": set_count}) is likewise refused — a setter is not a handler, and the TypeError says so and shows you the fix.

What is accepted: a plain async def, a callable object whose __call__ is a coroutine, and a functools.partial or functools.wraps wrapper around either — _unwrap_handler in core/events.py peels partials and then runs inspect.unwrap before asking whether the result is a coroutine.

A string is not a handler — it is JavaScript

ReactPy has a second, easy-to-miss form. If the value of an on* key is a string, ReactPy treats it as JavaScript to run in the browser instead of calling your Python code:

html.button({"onClick": "alert('You clicked me!')"}, "Click me")

That renders into an inlineJavaScript field on the VDOM node rather than an eventHandlers field, and nothing on the server ever runs. It is a real escape hatch for things only the browser can do, but it is never what you want when you meant to write a Python function. If your handler silently does nothing, check whether you passed a string.

Pass the function, don't call it

Functions passed to event handlers must be passed, not called:

html.button({"onClick": handle_click}, "...")    # correct: fires on click
html.button({"onClick": handle_click()}, "...")  # wrong: fires during render

In the second case the () calls handle_click immediately during rendering, without any clicks — because the Python inside the call is evaluated right away, exactly like an expression inside JSX braces. Whatever it returns is what gets stored. Handlers are picked out by callable(v), so a None return is not a handler: it falls through to the plain attributes and ships as {"onClick": None}. The client never turns that into a DOM attribute — a value under an on* name is consumed as a listener, and a falsy one means "register nothing". Nothing fires, and the button renders and does nothing, forever.

If handle_click is a coroutine function — the only shape a handler may have — then calling it does not run its body at all; it returns a coroutine object. That is not callable either, so it takes the same fall through to the attributes as None does. The difference is what happens next: the layout renders, and the failure appears when the model is encoded for the wire — TypeError: Type is not JSON serializable: coroutine out of the orjson.dumps on send (executors/asgi/middleware.py). So one form of this mistake gives you a silently dead button, the other a traceback at send time. Either way, pass the function; do not call it.

The same trap appears with inline code:

# This prints when the component renders, not when clicked!
html.button({"onClick": print("You clicked me!")}, "Click me")

# Wrap it in a named coroutine so it becomes a function to call later.
async def handle_click(event):
    print("You clicked me!")

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

In both working cases, what you pass is a function. A lambda cannot be the wrapper here, because a handler has to be async def.

Reading props in event handlers

Because event handlers are declared inside a component, they have access to the component's props. Here is a button that, when clicked, displays its message prop:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@component
def alert_button(message, *children):
    shown, set_shown = use_state("")

    async def handle_click(event):
        set_shown(message)

    return html.div(
        html.button({"onClick": handle_click}, *children),
        html.p(shown),
    )


@component
def toolbar():
    return html.div(
        alert_button("Playing!", "Play Movie"),
        alert_button("Uploading!", "Upload Image"),
    )
1
# TODO

This lets the two buttons show different messages. Try changing the messages passed to them.

Note the set_shown(message) line: the handler closes over the message prop, which is a plain function parameter, so this behaves like any other Python closure.

Passing event handlers as props

Often you'll want the parent component to specify a child's event handler. Consider buttons: depending on where you're using a button() component, you might want to execute a different function—perhaps one plays a movie and another uploads an image.

To do this, pass a prop the component receives from its parent as the event 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
@component
def button(on_click, *children):
    return html.button({"onClick": on_click}, *children)


@component
def play_button(movie_name):
    shown, set_shown = use_state("")

    async def handle_play_click(event):
        set_shown(f"Playing {movie_name}!")

    return html.div(
        button(handle_play_click, f'Play "{movie_name}"'),
        html.p(shown),
    )


@component
def upload_button():
    shown, set_shown = use_state("")

    async def handle_upload_click(event):
        set_shown("Uploading!")

    return html.div(
        button(handle_upload_click, "Upload Image"),
        html.p(shown),
    )


@component
def toolbar():
    return html.div(play_button("Kiki's Delivery Service"), upload_button())
1
# TODO

Here, toolbar() renders a play_button() and an upload_button():

  • play_button() passes handle_play_click as the on_click prop to the button() inside.
  • upload_button() passes handle_upload_click as the on_click prop.

Finally, the button() component accepts a prop called on_click and passes it straight to the built-in browser button with {"onClick": on_click}. This tells ReactPy to call the passed function on click.

Notice the two levels of naming: the prop your own component accepts is an ordinary Python parameter (on_click, snake_case), while the key handed to the browser tag is "onClick". A design system commonly puts styling in components like buttons but leaves the behavior to callers, so play_button() and upload_button() pass handlers down.

Naming event handler props

Built-in tags like html.button and html.div only support DOM event names as handler keys — "onClick", "onChange", and so on. When you build your own components, however, you can name their handler props anything you like, because they are just Python parameters.

By convention, name them on_ followed by the interaction:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@component
def button(on_smash, *children):
    return html.button({"onClick": on_smash}, *children)


@component
def app():
    status, set_status = use_state("Nothing selected")

    async def handle_play(event):
        set_status("Playing!")

    async def handle_upload(event):
        set_status("Uploading!")

    return html.div(
        html.p(status),
        button(handle_play, "Play Movie"),
        button(handle_upload, "Upload Image"),
    )
1
# TODO

In this example {"onClick": on_smash} shows that the browser button still needs the key "onClick", but the prop name received by your own button() component is up to you.

When a component supports multiple interactions, name handler props after app-specific concepts. A toolbar() could receive on_play_movie and on_upload_image. The calling app() does not need to know what toolbar() will do with them — that's an implementation detail. It could wire them to a click today and a keyboard shortcut tomorrow. Naming props after app-specific interactions gives you that flexibility.

Use the right tag

Use the semantically correct HTML tag for the interaction. To handle clicks, use html.button({"onClick": handle_click}) rather than html.div({"onClick": handle_click}). A real browser <button> gives you built-in behavior such as keyboard navigation and focus handling. If you dislike the default styling, restyle it with CSS rather than downgrading it to a <div>. Learn more about writing accessible markup.

The event object

Handlers receive the event data as their single positional argument. By convention it's called event.

It is not React's SyntheticEvent, and it is not a serialized snapshot either. There is nothing to persist and no native object to unwrap: persist() and nativeEvent are React's answers to a pooling problem this design never had. They are not members of Event — but __getattr__ will happily hand you a proxy for either name, truthy and waiting to fail, so if event.persist: and hasattr(event, "nativeEvent") both say yes and only the await raises EventProxyNotFound. Never duck-type a proxy by attribute; read what you know is there. What your handler holds is a reactpy.types.Event — a live, lazily resolved view onto the browser's object (_event_proxy.py, Event). Reading any property of it hands back another proxy, and awaiting the proxy is what asks the browser for the value:

async def handle_change(event):
    value = await event.target.value   # one round trip
    await event.target.focus()         # methods are callable too

The property chain is spelled exactly as it is in JavaScript. The one difference is the await, because the value lives in the browser rather than in this process. There is no dictionary behind it: event["target"]["value"] raises TypeError: 'Event' object is not subscriptable, because a proxy is deliberately not a container and has no __getitem__, __iter__, or __len__.

Nothing is fetched until a handler asks for it, so a handler which only calls set_state() never sends a request. Two things keep the count down for the handlers which do read. A property which has been read is remembered against its event, so reading it twice costs one trip. And properties which are in flight at the same moment go out as a single message:

value, key = await event.read("target.value", "key")
value, key = await asyncio.gather(event.target.value, event.key)

Both cost one round trip. Two await expressions written one after the other cannot be batched — the second is not known until the first has landed.

A read belongs to the moment it is awaited

await event.target.value reports what the element holds now, just as the same expression would in JavaScript — not what it held when the keystroke fired. A handler still queued behind another event's work sees whatever that element has become by the time it runs. A handler which must act on the value as its own event saw it has to read it before anything else can change it.

Reads are cached too, so a second read hands back what the first one found however much the page has moved on since. Ask the browser what it holds by now with refresh:

(latest,) = await event.read("target.value", refresh=True)

Nothing is quietly answered with None. A property the browser will not give — one it blocks, an object it has forgotten, a path that is not there — raises a subclass of EventProxyError (Unavailable, Timeout, NotFound, Evicted, AccessError). A typo such as event.target.vallue is therefore an error, not a silent None. What is quiet is the mistake in the other direction: leave the await off and you hold a proxy, which is always truthy and never raises on its own — it reaches set_state() or an f-string as its own repr, or fails at the wire. A None you did read is a real answer from the page: a property that exists and holds null comes back as one.

The reads worth knowing:

Read Meaning
await event.target.value Text of an <input>, <textarea>, or selected <option>
await event.target.checked Whether a checkbox or radio is on — read it from "onChange", which is the event that fires when one is toggled
await event.target.files Files from an <input type="file">
await event.key Which key, on onKeyDown / onKeyUp
await event.type The event name, e.g. "click" — the one value the client volunteers with the id, so awaiting it sends nothing
await event.target.dataset The element's data-* attributes
await event.composedPath() The event's path, read from the browser

Since the client holds the real object instead of serializing it, that list is a shortlist and not a ceiling. Anything the browser can answer is readable, and whatever it cannot turn into JSON — a DOM node, a DataTransfer — comes back as a reference which is itself a proxy, so you can keep walking. What the client refuses is anything that could be used to break out of the page (window, document, parentNode, __proto__, eval, ...) and anything that looks like an event handler, which is matched as /^on[a-z]/i and so includes onPointerDown. Whole-object views omit methods; an explicit method path still returns something callable.

Form submits

For onSubmit the target is the <form> itself, which means value is not there to read — await event.target.value raises EventProxyNotFound, and batching it into a read() fails the whole read. A form does have a name, so (name,) = await event.read("target.name") works; to reach what was typed, ask for the control collection: controls = await event.target.elements, then index c["value"] on the entries it returns. See Communicate Data Between Server and Client.

Event propagation

Event handlers also catch events from any children a component has. We say that an event "bubbles" or "propagates" up the tree: it starts where the event happened, and then goes up.

ReactPy attaches an ordinary DOM listener to each element, so bubbling is the browser's native behavior rather than something ReactPy implements. That has one consequence worth knowing: unlike React, which delegates every event to the document root and then replays it down your component tree, ReactPy's handlers follow the real DOM tree. A parent handler fires for events from anything nested inside it, including elements you did not render yourself.

This <div> contains a button. Both the <div> and the button have their own "onClick" handlers. Which do you think fire when you click the button?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@component
def toolbar():
    messages, set_messages = use_state([])

    async def handle_toolbar_click(event):
        set_messages([*messages, "You clicked on the toolbar!"])

    async def handle_play_click(event):
        set_messages([*messages, "Playing!"])

    return html.div(
        {"className": "Toolbar", "onClick": handle_toolbar_click},
        html.button({"onClick": handle_play_click}, "Play Movie"),
    )
1
2
3
4
5
6
7
8
.toolbar {
    background: #aaa;
    padding: 5px;
}

button {
    margin: 5px;
}
1
# TODO

If you click the button, its "onClick" runs first, followed by the parent <div>'s. Two messages appear. If you click the toolbar itself, only the parent's handler runs.

Scroll does not bubble

Because propagation is the browser's own, the browser's exceptions apply. "onScroll" does not bubble: attaching it to an ancestor will not catch scrolls from descendants. Attach it to the element that actually scrolls.

Stopping propagation

To prevent an event from reaching parent components, call event.stopPropagation():

 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
@component
def button(on_click, *children):
    async def handle_click(event):
        # Detected from this line, so the client stops the event before
        # it reaches the parent's handler.
        event.stopPropagation()
        on_click()

    return html.button({"onClick": handle_click}, *children)


@component
def toolbar():
    messages, set_messages = use_state([])

    async def handle_toolbar_click(event):
        set_messages([*messages, "You clicked on the toolbar!"])

    def handle_play_click():
        set_messages([*messages, "Playing!"])

    return html.div(
        {"className": "Toolbar", "onClick": handle_toolbar_click},
        button(handle_play_click, "Play Movie"),
    )
1
2
3
4
5
6
7
8
.toolbar {
    background: #aaa;
    padding: 5px;
}

button {
    margin: 5px;
}
1
# TODO

Now clicking the button produces only "Playing!".

ReactPy detects this call by reading your bytecode

Calling event.stopPropagation() inside a handler does not itself stop anything: by the time your handler runs, the event has already bubbled. What stops propagation is a flag sent to the browser in advance, and ReactPy derives it by inspecting the bytecode of your handler (core/_event_inspect.py, inspect_event_handler).

It looks for .stopPropagation (and .preventDefault, and .stopImmediatePropagation) called directly on the first parameter. That produces some sharp edges, all of them observable:

  • The handler must take a parameter at all. async def handler(): can never be detected.
  • It must be the first positional parameter. Renaming it is fine; the position is what matters.
  • Rebinding defeats it. e = event followed by e.stopPropagation() is not detected, and propagation silently continues.
  • Passing the event into a helper that calls it is likewise not detected — the inspector reads one function's bytecode and does not follow calls.

When in doubt, skip detection and declare the intent explicitly:

from reactpy import event

@event(stop_propagation=True, prevent_default=True)
async def handle_click(event): ...

Because the live proxy keeps the browser object alive, the call is also forwarded to it without being waited for — but by then the browser has usually moved on, so rely on the handler flag when the action truly must not happen. event.propagationStopped and event.defaultPrevented answer synchronously, and with what Python asked for. What the browser settled on is a property like any other: (prevented,) = await event.read("defaultPrevented"), which is worth the round trip when other scripts on the page may have prevented the action too.

Preventing default behavior

Call event.preventDefault() the same way to stop the browser's built-in action. The classic case is a form submit, which would otherwise navigate away from the page:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@component
def signup():
    signed_up, set_signed_up = use_state(0)
    name, set_name = use_state("")

    async def handle_name_change(event):
        set_name(await event.target.value)

    async def handle_submit(event):
        # Without this line the browser would navigate away from the page.
        event.preventDefault()
        set_signed_up(signed_up + 1)
        set_name("")

    return html.form(
        {"onSubmit": handle_submit},
        html.input({"value": name, "onChange": handle_name_change}),
        html.button({"type": "submit"}, "Sign up"),
        html.p(f"{signed_up} signed up"),
    )
1
# TODO

The same detection rules — and the same @event(prevent_default=True) escape hatch — apply.

Capture-phase handlers

To see an event on the way down, before your children handle it, append Capture to the event name:

async def handle_toolbar_click(event):
    ...


async def handle_play_click(event):
    ...


html.div(
    {"onClickCapture": handle_toolbar_click},
    html.button({"onClick": handle_play_click}, "Play Movie"),
)

ReactPy forwards the key untouched — there is no capture logic in vdom.tsx at all. The suffix is honoured one layer down, by Preact, which strips a trailing Capture, registers the listener with addEventListener(name, handler, true), and dispatches "click" to it in the capture phase. So "onClickCapture" fires before the button's own "onClick", and the ordering is the browser's, not ReactPy's. Nothing in ReactPy checks that the name you gave is a real event, which is why the same freedom lets "onMyThing" through and lets "on_click" register a listener for "_click" that nothing ever dispatches.

Can event handlers have side effects?

Yes. Unlike component bodies, which must stay pure, event handlers run in response to an action rather than during rendering, so they are the correct place to change things. See Where you can cause side effects for where side effects belong and why.

Recap

  • Declare the handler with async def. A def, a lambda, or a state setter is refused with a TypeError when the element is built.
  • Add a handler by putting the DOM event name in camelCase into the attributes dictionary: {"onClick": handle_click}.
  • Only names you define are snake_case; "on_click" renders but never fires.
  • Pass a function, never the result of calling one; a string value means client-side JavaScript, not Python.
  • Handlers may read props, call setters, and have side effects.
  • The event object is a live proxy onto the browser object, not a snapshot and not a SyntheticEvent. Read await event.target.value and await event.key; batch with await event.read("target.value", "key"). Subscripting it raises.
  • Events bubble natively, with the browser's own exceptions such as "onScroll". Stop them with event.stopPropagation(), which is detected from your bytecode — or declare it with @event(stop_propagation=True).
Authors: Archmonger