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.
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)Python describes the UI. React runs it.
← State deltas / frontend commands
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.
How your Python becomes browser code
From Python objects to a runnable browser app
Follow the artifacts. UI declarations produce React modules; Python event handlers stay on the backend.
RequestResponseLocal workExecute the page-building code
Python calls component constructors. This creates objects describing the interface, not HTML in a browser.
Timeline & playback controls
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.
First load has two different “hydrations”
From an empty tab to live state
HTTP brings the interface. The socket brings this session’s data. Follow both trips.
RequestResponseLocal workRequest the document
The browser requests / from the frontend. This is HTTP, before the Reflex state socket exists.
Timeline & playback controls
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.
Reflex state hydration replaces the build-time defaults with this visitor's state from Python.
State.hydrate, then on_load_internal.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.
From click to socket
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 workReact 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
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 → renderFollow one click, one layer at a time
A click is a round trip, not new HTML
Watch count stay at 0 in the browser until the server’s delta arrives.
RequestResponseLocal workClick 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
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.
The screen is ready
Both sides agree after hydration. The browser holds public fields; Python also holds _handled.
Browser's app state
Python's app state
Selected layer's data
What crosses the wire?
| Where | Form |
|---|---|
| Browser input | event.target.value === "Lin" |
| Reflex event | {name: LAB + ".rename", payload: {value: "Lin"}, router_data:
…} |
| Socket.IO frame | 42/_event,["event",{…}] |
| Python handler call | rename(value="Lin") |
| Python reply | StateUpdate(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.
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.
From event name to Python mutation
- Find the session.
EventNamespace.on_eventlooks up the token linked to this socket and builds anEvent. - Queue it. The event processor schedules the handler for that token.
- 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.
- Track changes. Assigning
self.countmarks it dirty, which also invalidatesdoubled. - 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.
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).
What happens when you click Details?
New page. Same counter. Same connection.
This sequence begins after one click: count is 1 and the Home page has one visit.
RequestResponseLocal workFollow the internal link
The app link requests client-side navigation to /details. This is a local browser action.
Timeline & playback controls
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.
| Action | What happens |
|---|---|
| Click an internal link | Same document and socket. No full hydrate; on-load runs. |
| Refresh | New document and socket. The token survives in sessionStorage, so hydrate restores the saved state, then on-load runs. |
| Open in a fresh browser | New token, so state starts from defaults. |
| Change only the query string | On-load runs again with the new query. Query values arrive as strings; validate them on the server. |
Browser copy, session state, and durable records
Why initialState contains more than your counter
Follow build-time placeholders as they become session-specific values. Only public fields cross to React.
RequestResponseLocal workShip 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
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.
See it in DevTools
- Open Network, enable Preserve log, and refresh.
- Filter to WS and pick the
/_eventconnection (not Vite's hot-reload socket). - In Messages, find
hydrateandon_load_internal, then click Add one and compare the request with the reply. - Rename to Lin, then click Details and confirm the count stays.
- For breakpoints:
applyEventbeforesocket.emitinutils/state.js, your handler in Python, andapplyDeltawhen 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.