September 13, 2026

What actually happens
when you click?

How Reflex turns Python into a React interface, and moves data between your browser and server.

Reflex lets you write a web app entirely in Python. It compiles your UI into a React app that runs in the browser, while your state and event handlers stay in Python on the server. This post follows one small app through compilation, first load, a click, and a page change, using real captured messages.

01 / The example

A two-page counter

Home /

Hello, Ada!
Name input: Ada
Count: 0 · Doubled: 0
Button: Add one

Typing calls rename(value). Clicking calls increment(). Loading calls enter_home().

Details /details

Same state, another route
Shows the same greeting and count.
Has another increment button.

Navigating calls enter_details(). The count survives the route change.

greeting is computed from name, and doubled from count. _handled is a backend-only counter: the underscore keeps it out of everything sent to the browser.

Full app source
import reflex as rx


class LabState(rx.State):
    name: str = "Ada"
    count: int = 0
    visits: int = 0
    page: str = "Not loaded"
    _handled: int = 0

    @rx.var
    def greeting(self) -> str:
        return f"Hello, {self.name}!"

    @rx.var
    def doubled(self) -> int:
        return self.count * 2

    @rx.event
    def rename(self, value: str):
        self.name = value
        self._handled += 1

    @rx.event
    def increment(self):
        self.count += 1
        self._handled += 1

    @rx.event
    def enter_home(self):
        self.page = "Home"
        self.visits += 1

    @rx.event
    def enter_details(self):
        self.page = "Details"
        self.visits += 1


def layout(content: rx.Component) -> rx.Component:
    return rx.center(
        rx.vstack(
            rx.heading("Reflex flow lab", size="8"),
            rx.text("One Python state. Two routes. Watch every round trip."),
            rx.hstack(rx.link("Home", href="/"), rx.link("Details", href="/details")),
            rx.text("Server page: ", LabState.page, " · Page loads: ", LabState.visits),
            rx.text("State synchronized: ", rx.cond(rx.State.is_hydrated, "yes", "no")),
            rx.separator(),
            content,
            rx.separator(),
            spacing="5",
            width="100%",
            max_width="680px",
            padding="32px",
        ),
        min_height="100vh",
    )


def index() -> rx.Component:
    return layout(
        rx.vstack(
            rx.heading(LabState.greeting, id="greeting"),
            rx.text("Your name"),
            rx.input(
                value=LabState.name,
                on_change=LabState.rename,
                id="name",
                aria_label="Your name",
            ),
            rx.text("Count: ", LabState.count, id="count"),
            rx.text("Doubled: ", LabState.doubled, id="doubled"),
            rx.button("Add one", on_click=LabState.increment, id="increment"),
            width="100%",
            spacing="4",
        )
    )


def details() -> rx.Component:
    return layout(
        rx.vstack(
            rx.heading("Same state, another route"),
            rx.text(LabState.greeting),
            rx.text("Count: ", LabState.count, " · Doubled: ", LabState.doubled),
            rx.button("Add one here too", on_click=LabState.increment),
            spacing="4",
        )
    )


app = rx.App()
app.add_page(index, route="/", on_load=LabState.enter_home)
app.add_page(details, route="/details", on_load=LabState.enter_details)
02 / Two runtimes

Python describes the UI. React runs it.

Page functions run once, at compile time. They build a component tree that becomes React code. A click sends an event name over a WebSocket; Python runs the handler against that visitor's state and sends back only the fields that changed. React re-renders from those fields. No HTML is generated per click.

03 / Before the visitor arrives

How your Python becomes browser code

Figure 01 · Follow the data

From Python objects to a runnable browser app

Follow the artifacts. UI declarations produce React modules; Python event handlers stay on the backend.

RequestResponseLocal work
From Python objects to a runnable browser app
01 · Local processing

Execute the page-building code

Python calls component constructors. This creates objects describing the interface, not HTML in a browser.

Timeline & playback controls
1 / 8
Compilation artifact inspector

Local processing in Python process

Execute the page-building code

rx.button("Add one", on_click=LabState.increment, id="increment")

Compilation artifacts, not event payloads. Excerpts are real; timing is illustrative.

Illustrative timing, not measured latency.

Reflex is a UI compiler, not a Python-to-JavaScript translator. It runs your page functions, turns the resulting components into React modules, and leaves your state class and handlers as Python on the server.

Page functions build a component tree

# What you write:
rx.button("Add one", on_click=LabState.increment, id="increment")

# What Python builds in memory (shortened):
Center
└── VStack
    ├── Text("Count: ", Var(LabState.count))
    └── Button("Add one", on_click=EventHandler(LabState.increment))

Because this runs at build time, a plain Python if LabState.count > 0 can't choose what a visitor sees later. Use rx.cond and rx.foreach for anything that depends on state.

State references become JavaScript expressions

Inside a page function, LabState.count is a Var: a placeholder that compiles to a read from a React context. Inside a handler, self.count is the real Python integer for one session.

# In a page function:
LabState.count
# compiles to:
reflex___state____state__flow_lab___flow_lab____lab_state.count_rx_state_

# In a handler:
self.count  # e.g. 1

Handlers stay in Python

The button's on_click compiles to a callback that sends the handler's name. The body of increment() never reaches the browser.

// Readable equivalent of the generated code:
const onClick = (_e) => addEvents([
  ReflexEvent("reflex___state____state.flow_lab___flow_lab____lab_state.increment")
]);
return jsx(RadixThemesButton, { id: "increment", onClick }, "Add one");
Generated button React module — actual excerpt
export const Button_button_5b96ea87d6d46c65a36c4e74327104a9_4c100443 = memo(({children}) => {
    const ref_increment = useRef(null); refs["ref_increment"] = ref_increment;
const on_click_f878fea372bb8c958470a4b8a1cc88df = useCallback(((_e) => (addEvents([(ReflexEvent("reflex___state____state.flow_lab___flow_lab____lab_state.increment", ({  }), ({  })))], [_e], ({  })))), [addEvents, ReflexEvent])



    return(
        jsx(RadixThemesButton,{id:"increment",onClick:on_click_f878fea372bb8c958470a4b8a1cc88df,ref:ref_increment},children)
    )
});
Button_button_5b96ea87d6d46c65a36c4e74327104a9_4c100443.displayName = "Button";

Defaults, then bundling

The compiler also writes each state's default values into .web/utils/context.js so the first render has something to show. These are build-time defaults, not any visitor's data. Vite and React Router then bundle the generated project into JavaScript and CSS. None of this reruns when state changes; only editing your source triggers a recompile.

04 / An empty tab becomes a working app

First load has two different “hydrations”

Figure 02 · Follow the data

From an empty tab to live state

HTTP brings the interface. The socket brings this session’s data. Follow both trips.

RequestResponseLocal work
From an empty tab to live state
01 · Request →

Request the document

The browser requests / from the frontend. This is HTTP, before the Reflex state socket exists.

Timeline & playback controls
1 / 8
Packet inspector

Browser → Frontend server

HTTP or build output · illustrative representation

GET / HTTP/1.1
Host: localhost:3037

Illustrative. Recorded Socket.IO frames start once the backend connection opens.

Illustrative timing, not measured latency.

React hydration attaches React to the initial document.
Reflex state hydration replaces the build-time defaults with this visitor's state from Python.
Step
Browser / frontend
Python / backend
1
GET / returns an HTML shell with script and style links.
No handler runs.
2
React mounts and renders the build-time defaults (“Hello, Ada!”).
The real session may already hold a different name and count.
3
Read or create a token in sessionStorage; open the socket with it.
Map the socket to that token.
4
Browser sends State.hydrate, then on_load_internal.
5
Receive the full public state and merge it into React.
Load or create the session's state and send all public fields.
6
Receive page="Home", visits=1, then is_hydrated=true.
Run enter_home, then mark the session hydrated.

The HTML is not rendered per visitor: this app builds with ssr: false, so every visitor gets the same shell. Current data always arrives over the socket.

05 / Inside the browser

From click to socket

Figure 03 · Follow the data

Inside the browser: from DOM click to socket

The browser scheduler runs JavaScript. Reflex’s event queue is an array inside that JavaScript, not another thread.

RequestResponseLocal work
Inside the browser: from DOM click to socket
01 · Local processing

React invokes the generated callback

The browser delivers the interaction and React invokes the button callback. The next local phases describe a call chain, not separate browser tasks.

Timeline & playback controls
1 / 10
Packet inspector

Local processing in DOM + React

Local code / state operation

DOM click → React callback(_e)
Call stack: generated onClick

Local processing; nothing is sent. Step to a request or response to see a recorded message.

Illustrative timing, not measured latency.

1. The callback builds a Reflex event

// The generated callback:
const onClick = (_e) => addEvents([
  ReflexEvent(LAB + ".increment", {}, {})
], [_e], {});

// The event it produces:
{ name: "reflex___state____state.flow_lab___flow_lab____lab_state.increment" }

The DOM event is never sent. For the name input, the callback copies _e.target.value into payload.value; the increment button has no arguments, so its payload is empty.

2. addEvents, queueEvents, processEvent

addEvents applies any event actions (preventDefault, stopPropagation, throttle, debounce), then queueEvents pushes the event onto a plain JavaScript array. processEvent shifts it off and sends it:

if (statefulWorkQueued && !socket.connected) return;
const event = event_queue.shift();

if (event.handler) {
  await applyRestEvent(event, ...);   // uploads and other REST paths
} else {
  await applyEvent(event, ...);       // socket.emit("event", event)
}

If the socket is down, events wait in the array until it reconnects. A page refresh discards them.

3. JavaScript doesn't wait for Python

socket.emit returns immediately. The reply arrives later through a separate socket listener, which merges it into React state.

Click callback → event queue → socket.emit()
                                 │
                       Python processes event
                                 │
Later socket callback ← StateUpdate.delta
          ↓
React reducer dispatch → updated context → render
06 / Step through the round trip

Follow one click, one layer at a time

Figure 04 · Follow the data

A click is a round trip, not new HTML

Watch count stay at 0 in the browser until the server’s delta arrives.

RequestResponseLocal work
A click is a round trip, not new HTML
01 · Local processing

Click Add one

The generated React callback builds a named event. It does not send count=1 or the complete DOM click object.

Timeline & playback controls
1 / 7
Packet inspector

Local processing in Browser

Local code / state operation

on_click=LabState.increment
→ ReflexEvent(LAB + ".increment")

Local processing; nothing is sent. Step to a request or response to see a recorded message.

Illustrative timing, not measured latency.

Pick an action and step through it. Each starts from the same hydrated Home page, and the packets match the captured app.

Step 1 of 6 · Simulation

The screen is ready

Both sides agree after hydration. The browser holds public fields; Python also holds _handled.

1 · BrowserDOM + React state
⇄
2 · WireSocket.IO event / delta
⇄
3 · PythonSession state + handler

Browser's app state

Python's app state

Selected layer's data

 
07 / The same data, in different forms

What crosses the wire?

WhereForm
Browser inputevent.target.value === "Lin"
Reflex event{name: LAB + ".rename", payload: {value: "Lin"}, router_data: …}
Socket.IO frame42/_event,["event",{…}]
Python handler callrename(value="Lin")
Python replyStateUpdate(delta={LAB: {"name_rx_state_": "Lin", "greeting_rx_state_": "Hello, Lin!"}})
Browser merge{…oldState, …delta[LAB]}

LAB is shorthand for reflex___state____state.flow_lab___flow_lab____lab_state.

Rename request

{
  "name": "reflex___state____state.flow_lab___flow_lab____lab_state.rename",
  "payload": {"value": "Lin"},
  "router_data": {"pathname": "/", "asPath": "/"}
}

Rename response

{
  "delta": {
    "reflex___state____state.flow_lab___flow_lab____lab_state": {
      "name_rx_state_": "Lin",
      "greeting_rx_state_": "Hello, Lin!"
    }
  }
}

The request carries no session token; the server already knows it from the socket connection. The response contains only the changed field and the computed field that depends on it.

The raw frame

42/_event,["event",{"name":"reflex___state____state.flow_lab___flow_lab____lab_state.rename","payload":{"value":"Lin"},"router_data":{"pathname":"/","asPath":"/"}}]

4 is the Engine.IO message type, 2 the Socket.IO event type, /_event the namespace, and "event" the event name. Everything after that is JSON, so Python values arrive as JSON numbers, strings, booleans, arrays and objects. Nothing is pickled, and types beyond that need a Reflex serializer.

08 / Evidence from the working app

Read the real conversation

Recorded with headless Chromium: load Home → increment → rename to Lin → open Details → refresh. Client IDs and socket keys are redacted.

WebSocket text

Decoded event or handshake

The first hydrate sends everything (count 0, name Ada). Increment sends only count and doubled. Rename sends only name and greeting. After the refresh, a new connection hydrates count 1 and name Lin from the saved session.

09 / Inside the server

From event name to Python mutation

  1. Find the session. EventNamespace.on_event looks up the token linked to this socket and builds an Event.
  2. Queue it. The event processor schedules the handler for that token.
  3. Lock and run. The state manager loads this token's state and locks it while the handler runs, so two visitors never share a counter.
  4. Track changes. Assigning self.count marks it dirty, which also invalidates doubled.
  5. Emit the delta. get_delta() collects the dirty public fields and sends them to that token's socket.

_handled changes too, but underscore fields never appear in a delta. doubled is recomputed in Python and sent as a value; the browser never runs the getter. The state lock is not a database transaction, so database writes still need their own.

10 / Back in the browser

A delta changes state; React changes the DOM

// The reducer, in full:
export const applyDelta = (state, delta) => {
  return { ...state, ...delta };
};

// Applied to the counter reply:
dispatch[LAB]({ count_rx_state_: 1, doubled_rx_state_: 2 });

The merge is shallow: fields missing from the delta keep their values, and a changed list is sent whole. React re-renders whatever reads those fields. If a delta names a substate the frontend doesn't know, the frontend and backend builds are out of sync and Reflex reports an error.

The counter waits for the server's reply before it changes. Text inputs feel instant because the input keeps its own value and Reflex debounces the event (300 ms here).

11 / A new URL, the same session

What happens when you click Details?

Figure 05 · Follow the data

New page. Same counter. Same connection.

This sequence begins after one click: count is 1 and the Home page has one visit.

RequestResponseLocal work
New page. Same counter. Same connection.
01 · Local processing

Follow the internal link

The app link requests client-side navigation to /details. This is a local browser action.

Timeline & playback controls
1 / 6
Packet inspector

Browser → React Router

Local code / state operation

rx.link("Details", href="/details")

Local processing; nothing is sent. Step to a request or response to see a recorded message.

Illustrative timing, not measured latency.

React Router swaps the page without reloading the document, so the socket and state stay. The browser sends on_load_internal with the new path; Python runs enter_details and sends back the router, page and visits changes. The new page can render before its on-load handler finishes, so check is_hydrated when that matters.

ActionWhat happens
Click an internal linkSame document and socket. No full hydrate; on-load runs.
RefreshNew document and socket. The token survives in sessionStorage, so hydrate restores the saved state, then on-load runs.
Open in a fresh browserNew token, so state starts from defaults.
Change only the query stringOn-load runs again with the new query. Query values arrive as strings; validate them on the server.
12 / Where does the data live?

Browser copy, session state, and durable records

Figure 06 · Follow the data

Why initialState contains more than your counter

Follow build-time placeholders as they become session-specific values. Only public fields cross to React.

RequestResponseLocal work
Why initialState contains more than your counter
01 · ← Response

Ship the default state shape

The compiler emits both the root and app substates. It knows Ada and count=0, but not this visitor’s headers, URL, token, or SID.

Timeline & playback controls
1 / 5
Packet inspector

Compiler → Browser contexts

HTTP or build output · illustrative representation

{
  "root": {
    "is_hydrated": false,
    "router_headers": {
      "host": ""
    },
    "router_session": {
      "client_token": "",
      "session_id": ""
    }
  },
  "LabState": {
    "name": "Ada",
    "count": 0
  }
}

Illustrative. Recorded Socket.IO frames start once the backend connection opens.

Illustrative timing, not measured latency. Field names are shortened in this figure.

Browser

  • DOM: what's on screen.
  • React contexts: a copy of public state.
  • sessionStorage: the client token.
  • Optional cookie / localStorage fields you declare.

Server

  • State manager: the real session state (disk by default, or memory/Redis).
  • Backend-only fields and handlers.
  • Your database, if you use one, for anything that must last.

Session state can be evicted or expire, so treat it as a cache for UI state, not storage. The token identifies a tab session, not a user, so it is no substitute for authentication. Anything in public state is visible to the visitor, even if no component shows it; keep secrets in underscore fields and check permissions inside handlers.

13 / Verify with your own eyes

See it in DevTools

  1. Open Network, enable Preserve log, and refresh.
  2. Filter to WS and pick the /_event connection (not Vite's hot-reload socket).
  3. In Messages, find hydrate and on_load_internal, then click Add one and compare the request with the reply.
  4. Rename to Lin, then click Details and confirm the count stays.
  5. For breakpoints: applyEvent before socket.emit in utils/state.js, your handler in Python, and applyDelta when the reply lands.

Common wrong mental models

  • “Python runs in the browser.” The browser runs React; Python stays on the server.
  • “The server sends HTML for every click.” It sends a state delta.
  • “The browser sends the new count.” It sends a handler name; Python does the increment.
  • “Every update sends all fields.” Only hydration does. Normal updates send changed fields plus dependent computed ones.
  • “Each page has its own state.” Both pages share the session's LabState.