Skip to content

Communicating Data Between Server and Client

Overview

ReactPy connects a Python process and a browser tab with one websocket and one message type in each direction. That single constraint decides how every kind of data moves between the two: values the server knows flow down as rendered models, values the user produces flow up as event payloads, and anything else needs a deliberate detour. This page is a map of those routes — what crosses, what cannot, and the honest workaround for each.

You will learn

  • Which channels actually exist, and the one direction that is not supported
  • How to push a server-side value into a view without the client asking
  • What an event payload really contains — including why file uploads do not work the way people expect
  • How to get real file bytes across, and how to keep blocking code off the loop

The channels that exist

Direction Mechanism Your control
server → client the rendered model, sent as layout-update patches total — return it from the component
client → server an event handler, receiving layout-event total — read event
server → client, arbitrary message none not supported
shared mutable state none the two sides hold different objects

The server-to-client push row is the one people look for first, and its absence is a design choice, not an omission. A component's Connection has no send, no deliver, no close (User Connection), and the backend is the only code allowed to write to the socket. That keeps the model the single source of truth: if a component could scribble on the wire, the client's tree and the server's model would drift apart with nothing to reconcile them.

A hand-written message is dropped by the client, silently

A custom backend can write anything to the socket, so it is tempting to invent a message type. Verified against a real browser: a {"type": "my-custom", ...} frame arrives, and the client logs

[ReactPy] Received message without handler Object

and discards it. The client dispatches on message.type against a fixed registry — "layout-update" is the only one registered by the mount path — and an unregistered type warns and returns. Nothing throws, the socket stays up, and the UI never changes. The client bundle exports exactly one function and offers no onMessage hook to register against, so there is no supported way to add a handler. Do not build on this path; use one of the routes below.

Down: push a value the client never asked for

Anything you can await on the server becomes a render, and a render reaches the client. An use_async_effect that loops is the whole pattern:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
async def fetch_price(symbol):
    """Stand-in for a request to an exchange, a sensor, a queue."""
    await asyncio.sleep(1)
    return f"{symbol}: 42.10"


@component
def ticker(symbol):
    price, set_price = use_state("loading...")

    async def poll():
        while True:
            try:
                set_price(await fetch_price(symbol))
            except Exception as error:
                set_price(f"error: {error}")
            try:
                await asyncio.sleep(1)
            except asyncio.CancelledError:
                break

    use_async_effect(poll)

    return html.div(html.h1(price))
1
# TODO

Verified end to end: the first patch carried ["loading..."], and about a second later a second patch arrived with ["BTC: 42.10"] — no client event in between. The client applied it like any other update.

Three things make or break this:

  • Use use_async_effect, not use_effect. An async def passed to use_effect raises TypeError: \use_effect` does not support async functions. Use `use_async_effect` instead.The effect takes **zero arguments** — the#!python async def effect(stop)shape from 1.x no longer works. Note where that error surfaces:#!python use_effectraises during the **render**, not inside the effect task, so you get a blank region — the model is#!python {"tagName": "", "error": ""}with debug off, and carries theTypeErrortext with#!python REACTPY_DEBUGon — and noError in effect` log line at all. That distinguishes it from a raising effect body, which is silent everywhere until unmount (Lifecycle of Reactive Effects).
  • Loop and honour cancellation. A long-lived effect is while True: with except asyncio.CancelledError: break. ReactPy cancels the task on unmount; shield=True opts out if the work must finish.
  • The dependency array is not React's. Omitted means inferred from the closure's free variables, dependencies=[] starts once, dependencies=None restarts every render. Never omit the array expecting "run after every render". See Use Async Effect.

Polling is fine for a demo and wrong at scale. For real work, prefer a server-sent queue that the effect awaits — the effect suspends on await queue.get() and the render happens the moment an item lands, with no timer at all.

Up: read what the client sends

An event handler receives a dict, not a live event — the browser serialized it and your code is now on the other side of a network boundary. The client walks the target and copies enumerable properties, plus value, checked, files, type, name read off the element even though they are accessors, plus dataset, plus — on submit — the form's named controls. Functions, DOM nodes, and cycles are skipped rather than raising.

Two consequences to internalize:

An <input> arrives as the whole element. A measured onChange payload carried roughly ninety keys — accept, formAction, validity, validationMessage, and the rest of HTMLInputElement — of which you read value. Index what you need; do not count on a specific key surviving, and do not iterate the payload assuming it is small.

On submit, event["target"] is a list, not a dict. The named controls are serialized as list entries, so the read shape differs from every other event:

def handle_submit(event):
    event.preventDefault()
    controls = event["target"]  # list of control dicts
    payload = next((c["value"] for c in controls if c.get("name") == "payload"), "")

This is verified: submitting a form with a hidden name="payload" input produced [{"name": "", "type": "file", ...}, {"name": "payload", "type": "hidden", "value": "hello world\n"}, {"name": "", "type": "submit", ...}]. The full working example is in Files below. The protocol behind this is documented in Protocol Structure.

Files: what actually crosses the wire

A file input does not send file contents. It sends file metadata. Verified with a real file chooser against a real browser — this is the entire files entry that arrived:

{
    "name": "upload.txt",
    "lastModified": 1791523914968,
    "lastModifiedDate": {},
    "webkitRelativePath": "",
    "type": "text/plain",
}

No bytes. File exposes its content through methods, which are functions, and functions are skipped by the walk. There is no FormData in the client and no chunked upload path. If you expected event["target"]["files"][0]["content"], it does not exist and never will from this mechanism.

Reading real bytes

The route is: read the file in the browser with JavaScript, put the text into a normal form field, and let an ordinary submit carry it. This works — verified in Chrome, with a 12-character file arriving server-side as 'hello world\n':

 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
READ_FILE = (
    "const file = event.target.files[0];"
    "if (file) {"
    "    file.text().then((text) => {"
    "        document.getElementById('payload').value = text;"
    "    });"
    "}"
)


@component
def text_upload():
    received, set_received = use_state("Choose a file, then send.")

    def handle_submit(event):
        event.preventDefault()
        # On submit, target is the list of *named* controls in the form.
        controls = event["target"]
        payload = next(
            (c["value"] for c in controls if c.get("name") == "payload"),
            "",
        )
        set_received(f"{len(payload)} characters received")

    return html.form(
        {"onSubmit": handle_submit},
        html.input({"type": "file", "onChange": READ_FILE}),
        html.input({"type": "hidden", "id": "payload", "name": "payload", "value": ""}),
        html.button({"type": "submit"}, "Send"),
        html.pre(received),
    )
1
# TODO

The mechanics, each of which matters:

  • The onChange value is a string, which routes it to inlineJavaScript instead of eventHandlers — that is what makes it run in the page (Creating VDOM Event Handlers).
  • file.text() returns a promise, so the assignment happens in a callback — which is why a submit button is required. Sending immediately from the change handler races the promise and ships an empty string.
  • The hidden input needs both id (for the JavaScript to find it) and name (for the form walk to include it).
  • event.preventDefault() must appear literally in the handler's body. ReactPy detects it by bytecode inspection; calling a helper that calls it is not detected, and the page navigates away. See the bound-method caveat in Creating VDOM Event Handlers.

Untrusted content and eval

The inline-JavaScript string is eval'd. Build it from constants only — f"...{user_input}..." is an injection point. And anything you read off a user's file is untrusted data: cap the size before you buffer it, and never render it back without escaping.

Binary uploads work the same way via file.arrayBuffer() plus base64, but base64 costs a third of your bandwidth on top of a JSON string over a socket. For anything large, upload to a normal HTTP endpoint and pass the resulting id through state instead.

Blocking code and other threads

Everything a component does runs on the connection's event loop. A synchronous, CPU-bound call inside a render or an effect freezes that connection — and the handlers on it. Move blocking work with asyncio.to_thread:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def blocking_render(width):
    """A synchronous, CPU-bound library call. Never run this on the loop."""
    time.sleep(0.4)  # stands in for image encoding, model inference, ...
    return f"rendered {width}px"


@component
def preview(width):
    result, set_result = use_state("rendering...")

    async def render():
        # to_thread keeps the loop free; the setter runs back on the loop.
        set_result(await asyncio.to_thread(blocking_render, width))

    use_async_effect(render, dependencies=[width])

    return html.h1(result)
1
# TODO

Verified: the first patch says rendering..., the next says rendered 300px, and the loop stayed responsive across the 0.4 s call. Note that the setter is called from inside the effect, which is already on the loop.

Calling a state setter from another thread loses the update

Handing set_value to a raw threading.Thread is the natural mistake, and it fails with no user-visible symptom. Verified — the setter raises on the worker thread, and no render ever arrives:

Failed to schedule render via <function _make_life_cycle_state.<locals>.<lambda> ...>
RuntimeError: no running event loop
RuntimeWarning: coroutine 'Layout._create_layout_update' was never awaited

Nothing appears in the browser console; the component simply never updates. The fix is to capture the loop while you are on it and marshal back:

loop = asyncio.get_running_loop()  # inside the component, on the loop

def deliver_from_thread(value):
    loop.call_soon_threadsafe(set_value, value)

Verified working — the update arrived as a normal patch. Prefer asyncio.to_thread when you can, since it removes the bookkeeping entirely.

Do not hang state on the component function

my_component.started = True as a run-once guard looks harmless and is a cross-user leak: the function object is module-scope, so the first visitor flips it for everyone. Give the component a prop, key the child, or guard with use_ref. The general version of this is in VDOM Mutations.

Client-only data

The mirror problem: something in the browser that Python cannot see. Computed style, scroll position, canvas pixels, localStorage, geolocation, the clipboard — none of it is in the payload, because the walk copies data properties and skips everything callable.

Your options, in order of preference:

  1. Move the computation server-side. If the value depends only on data you already have, compute it in Python. Server-side rendering is the default for a reason.
  2. Copy it into a form field and submit, exactly as the file example does. Clunky, and it works.
  3. Use the browser API in inline JavaScript and let it act locally — copy to clipboard, request fullscreen, scroll. Keep it to one call, and never interpolate user input into the string.
  4. Render the data yourself. A chart is usually better as a server-built <svg> or a generated image (reactpy.widgets.image) than as JavaScript in the page.

There is no use_ref-style handle to a DOM node, and {"ref": ...} either vanishes or throws in the browser — see Manipulating the DOM with Refs.

Summary

  • Only two channels exist: the model down, events up. An arbitrary server→client message is logged as Received message without handler and dropped.
  • Server push means use_async_effect plus a state setter — loop, handle CancelledError, and remember the dependency array infers by default.
  • Event payloads are serialized snapshots: an input arrives as the whole HTMLInputElement, and submit gives you a list of named controls.
  • File inputs send metadata only, never bytes. Read the file in the browser, put the text in a named hidden input, and submit.
  • Use asyncio.to_thread for blocking work. Calling a setter from a raw thread fails with no running event loop and no visible symptom — marshal with call_soon_threadsafe.
  • State never lives on the component function object; that is a leak across connections.
Authors: Archmonger