Skip to content

Protocol Structure

Overview

ReactPy renders on the server and paints in the browser. A single websocket carries two message types: the server sends "layout-update" patches, the client sends "layout-event" interactions.

server --{"type": "layout-update", "path": ..., "model": ...}-->  client
server <--{"type": "layout-event", "target": ..., "data": [...]}--  client

The connection

Every mounted component opens exactly one websocket. The client builds it from the pathPrefix and componentPath it was handed at mount time (static/index.js, mountReactPy):

ws://<host><pathPrefix><componentPath>?path=<page path>&qs=<page query string>

With the default REACTPY_PATH_PREFIX of "/reactpy/" and a component registered as "main.counter/", mounting on /page?tab=1 opens:

ws://127.0.0.1:8000/reactpy/main.counter/?path=%2Fpage&qs=%3Ftab%3D1

Two things about that URL are load-bearing:

  • The trailing slash after the dotted path is required. The server matches the websocket path against

    ^{path_prefix}(?P<dotted_path>[a-zA-Z0-9_.]+)/$
    

    which ends in /$. Without the slash the route never matches, the request falls through to your own ASGI app, and the handshake is refused. Verified from a page: ws://…/reactpy/main.counter/?path=/ opens, ws://…/reactpy/main.counter?path=/ fails with Unexpected response code, and the console logs [ReactPy] Failed to connect!.

  • path and qs are how the component learns its URL. ReactPy runs on the server, where the browser's location does not exist. The client copies window.location.pathname into path and window.location.search into qs; the server parses them back out (executors/asgi/middleware.py, _location_from_websocket_query_string) into the Location that use_location() returns. They are percent-encoded and carried in the websocket's own query string, so a server that normalises the handshake URL cannot silently rewrite them.

Reconnection does not re-read the mount

When the socket drops, the client waits and reconnects. Each attempt refreshes path and qs from the current page, but it never re-reads componentPath or the connect options — those were captured when the element mounted. A client-side navigation that changes which component belongs on the page needs a fresh mount, not a reconnect.

The client retries with exponential backoff: REACTPY_RECONNECT_INTERVAL (750 ms) times REACTPY_RECONNECT_BACKOFF_MULTIPLIER (1.25) per attempt, capped at REACTPY_RECONNECT_MAX_INTERVAL (60 s), for REACTPY_RECONNECT_MAX_RETRIES (150) tries. Those are arguments to mountReactPy(...); see ReactPyMiddleware for the bootstrap that emits them.


The server-to-client message

One message type goes down the wire. Its keys are fixed (reactpy.types.LayoutUpdateMessage):

Key Value Meaning
"type" "layout-update" Always this literal.
"path" str A JSON Pointer into the client's copy of the model. "" means replace everything.
"model" dict The value to place at that pointer.

The model is JSON, not a VdomDict. Six key names are legal (core/vdom.py, VDOM_JSON_SCHEMA), and only tagName is required:

Key Value Notes
"tagName" str The DOM tag, or "" for a wrapper element.
"children" list Nested models or plain strings.
"attributes" dict Sent to the client as written, and written to the DOM essentially verbatim. Two ReactPy-specific exceptions: key is read for reconciliation and consumed, never becoming an attribute, and _reactpy_ack_seq is deleted by the client before render.
"eventHandlers" dict Handler name to target spec, described below.
"inlineJavaScript" dict Handler name to JavaScript source string.
"importSource" dict Marks an element as coming from a JavaScript module.

Any other key name is rejected while the element is being built:

ValueError: Invalid keys: {'bogus'}.

Here is a real mount message for a counter, captured off the wire:

{
  "type": "layout-update",
  "path": "",
  "model": {
    "tagName": "",
    "children": [
      {
        "tagName": "",
        "children": [
          {
            "tagName": "",
            "children": [
              {
                "tagName": "div",
                "children": [
                  {"tagName": "h1", "children": ["count is 0"]},
                  {
                    "tagName": "button",
                    "eventHandlers": {
                      "onClick": {
                        "target": "/0/0/0/1:onClick",
                        "preventDefault": false,
                        "stopPropagation": false
                      }
                    },
                    "children": ["Add"]
                  }
                ]
              }
            ]
          }
        ]
      }
    ]
  }
}

Wrapper elements

Read from the top, that model is three elements with "tagName": "" before the div appears. ReactPy needs somewhere to put a component's output that is not itself a DOM node, and it does not reuse your tag for the purpose.

Do not depend on how many there are. The count is an implementation detail that shifts with structure: the same component produced one wrapper rendered bare through Layout, and three behind the ASGI executor's context provider. Never index the tree by position, and never assume the root has a tag name. The client renders a wrapper as a fragment, so it costs you nothing on screen.

Patches, not re-renders

The server does not resend the tree. When only an inner component's state changes, it sends the subtree rooted where that component renders:

{
  "type": "layout-update",
  "path": "/children/0/children/0",
  "model": {
    "tagName": "",
    "children": [
      {
        "tagName": "div",
        "children": [
          {"tagName": "h1", "children": ["count is 1"]},
          {
            "tagName": "button",
            "eventHandlers": {
              "onClick": {
                "target": "/0/0/0/1:onClick",
                "preventDefault": false,
                "stopPropagation": false
              }
            },
            "children": ["Add"]
          }
        ]
      }
    ]
  }
}

That is the whole reply to a click: one-third the depth of the mount, and the only text that changed is inside it. The pointer indexes the model, and path: "" replaces the root — which is what a state change in the topmost component produces.

A patch replaces rather than merges, so whatever subtree it carries is complete: keys you would expect to be sticky, eventHandlers among them, are resent inside it. Nothing is deleted by a patch; a smaller subtree simply arrives with fewer children.

Errors arrive as a model

A component that raises during render does not kill the socket. The layout sends an element whose tagName is empty and whose error is the message, which the client prints in a <pre> in place of the component:

{
  "type": "layout-update",
  "path": "",
  "model": {
    "tagName": "",
    "error": "ValueError: Expected a VDOM element dict, not {'noTag': True}"
  }
}

The schema ties the two together: when error is present, tagName must be empty. Without REACTPY_DEBUG=1 the same element carries "error": "" — an empty string, which the client renders as nothing, so the component silently disappears instead of showing a blank box.


The client-to-server message

One message type goes up (reactpy.types.LayoutEventMessage):

Key Value Meaning
"type" "layout-event" Always this literal.
"target" str The handler's target, copied from the model.
"data" list The arguments for the handler. Usually one event dict.
"seq" int Optional. A client-assigned counter, per element.

A real click on the button above:

{
  "type": "layout-event",
  "target": "/0/0/0/1:onClick",
  "data": [{"type": "click", "target": {"tagName": "BUTTON"}}],
  "seq": 1
}

How data is built

The browser serializes the DOM event by walking it: enumerable properties, plus value, checked, files, type and name read off the element even though they are accessors, plus dataset, plus every named control of a form on submit. Functions, DOM nodes, and anything else unserializable are skipped; a cycle or an object nested too deep is cut rather than raising.

That is why the handler receives a dict and not a live event, and why it cannot call into the page. It also means the contents depend on what the walk happened to reach: an <input> arrives as the whole element, hundreds of fields of which you read one. Index data positionally rather than counting on a specific key surviving.

Unpacking, and why the two handler forms differ

Layout._process_event_queue turns data into arguments and calls the handler — but not the same way for both kinds of handler (core/events.py, to_event_handler_function):

function(*data)   # a plain callable: the list is unpacked
function(data)    # an EventHandler: the list itself

So def f(event) receives the event dict when passed directly, while def f(data) must index into the list once wrapped in EventHandler. Converting a working handler just to add prevent_default breaks it in a way the traceback does not explain. Common Props shows both shapes side by side.

The target string

"/0/0/0/1:onClick" is two parts joined by a colon:

  • Before the colon, the element's position: child index, then child index, walking down from the root. It indexes the same tree the patch path does, so the two line up.
  • After the colon, the attribute name the handler was declared under. It is whatever key you used, which is why {"onMyThing": f} produces a target ending in :onMyThing.

Treat the whole thing as an opaque handle. It is generated per render and is not meant to be constructed by hand; two structurally identical elements get distinct targets because their index paths differ.

Events for unmounted elements are dropped, not queued

If the server has no handler for a target — because the element was removed since the client last rendered, or the target was mistyped — the event is discarded with an informational log and the socket stays open:

Ignored event - handler '/99/99:nope' does not exist or its component unmounted

ReactPy retries the lookup three times at 10 ms intervals first, to cover a handler that is briefly absent mid-render. That window is a race guard, not a queue: nothing is replayed later. A click that happens while the socket is down is held in a client-side send buffer and flushed if the connection reopens — but once REACTPY_RECONNECT_MAX_RETRIES is exhausted the client stops trying, and everything buffered stays unsent.

seq and typed input

Typing needs a protocol answer that a plain request/response does not give. Each keystroke sends an event carrying a counter the client incremented for that element. The server records the highest number seen per target, and on the next render of an <input>, <select>, or <textarea> stamps the largest one into the element's attributes under _reactpy_ack_seq:

{
  "type": "layout-update",
  "path": "/children/0/children/0",
  "model": {
    "tagName": "",
    "children": [
      {
        "tagName": "input",
        "attributes": {"value": "hello world", "_reactpy_ack_seq": 1},
        "eventHandlers": {
          "onInput": {
            "target": "/0/0/0:onInput",
            "preventDefault": false,
            "stopPropagation": false
          }
        }
      }
    ]
  }
}

The client compares that number against its own counter to decide whether the server has caught up with the latest keystrokes. If the server is still behind, the client keeps what the user typed instead of overwriting the box with a stale value. Without it, a slow handler erases text mid-word — the classic complaint about server-rendered inputs.

The value lives in a dedicated attribute rather than among your own so it cannot collide with, or leak into, a real DOM attribute. It is added only to those three tags, and only once at least one event has arrived for the element.

Debounce and throttle never reach the wire

They change when events leave the browser, not what is in them. Both are applied client-side before the message is built, and an invalid value is dropped with a console warning rather than an error. See Common Props.


Watching it yourself

The protocol is plain JSON text frames, so an ordinary websocket client is a sufficient observer. Start this app:

 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
from starlette.applications import Starlette
from starlette.responses import HTMLResponse
from starlette.routing import Route

from reactpy import component, html, use_state
from reactpy.executors.asgi import ReactPyMiddleware
from reactpy.executors.utils import server_side_component_html


@component
def counter():
    count, set_count = use_state(0)

    def increment(_event):
        set_count(count + 1)

    return html.div(
        html.h1(f"count is {count}"),
        html.button({"onClick": increment}, "Add"),
    )


async def home(request):
    return HTMLResponse(
        "<!doctype html>"
        "<html><body>"
        + server_side_component_html("root", "", "main.counter/")
        + "</body></html>"
    )


my_app = ReactPyMiddleware(
    Starlette(routes=[Route("/", home)]),
    root_components=["main.counter"],
)
1
# TODO

Then read the mount message, send a click, and read the patch:

 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
import asyncio
import json

import websockets


async def main():
    url = "ws://127.0.0.1:8000/reactpy/main.counter/?path=%2F"

    async with websockets.connect(url) as socket:
        mount = json.loads(await socket.recv())
        print("1. the mount update:", mount["path"] or "(root)")
        print(json.dumps(mount["model"], indent=2))

        target = find_handler_target(mount["model"])
        print("\n2. sending a click to", target)
        await socket.send(
            json.dumps(
                {
                    "type": "layout-event",
                    "target": target,
                    "data": [{"type": "click", "target": {"tagName": "BUTTON"}}],
                    "seq": 1,
                }
            )
        )

        patch = json.loads(await socket.recv())
        print("\n3. the reply is a patch at path:", repr(patch["path"]))
        print(json.dumps(patch["model"], indent=2))


def find_handler_target(model):
    """Return the target of the first event handler found in a model."""
    if isinstance(model, dict):
        for spec in (model.get("eventHandlers") or {}).values():
            return spec["target"]
        for child in model.get("children", []):
            found = find_handler_target(child)
            if found:
                return found
    return None


asyncio.run(main())
1
# TODO

Both were run against each other to produce every JSON block on this page. Nothing about the protocol is private to ReactPy's client — given a mount element and a correct target, you can drive a server-rendered component from any language.

The server trusts the client completely

Nothing checks that target names a real handler, that data describes a real event, or that the sender is the page that mounted the component. Whatever your handler reads out of data, it read because the browser asserted it. Treat it as user input: event["target"]["value"] is a string an attacker chose, at any length. The one thing the client cannot forge is the ASGI scope, which is server-side — see Use Connection.


Failure modes

Symptom Cause
Handshake refused, [ReactPy] Failed to connect! Missing trailing slash in componentPath, or a path_prefix that differs between the bootstrap and the server.
Socket opens then closes; server logs RuntimeError: Attempting to use an unregistered root component <path>. The dotted path is not in root_components. The socket is accepted before the component is resolved, so the failure is visible only in the server log.
Server log: Unknown message type: <type> Anything other than "layout-event". The server accepts that one type and drops the rest, leaving the connection open.
Server log: Ignored event - handler ... does not exist or its component unmounted Stale or wrong target.
Console: Received message without type or Received message without handler A frame the client has no handler for. Ignored; the connection stays up.
A <pre> with an exception message appears in the page The component raised. See Errors arrive as a model.
Component renders once and never updates The handler never fires — usually a snake_case event name such as on_click, so no event is ever sent.
[ReactPy] Disconnected! then Reconnecting in … The socket dropped and backoff is in effect. If Connection max retries exhausted! follows, the page needs a reload.

  • Common Props — the attribute names that become eventHandlers and inlineJavaScript.
  • Usage — writing the model by hand as a plain dict.
  • ReactPyMiddleware — the paths the server claims, and the bootstrap that opens the socket.
  • Use Connection and Use Location — the server-side view of the two query parameters.
Authors: Archmonger