Reflex State Management: How Python-Only Reactive State Actually Works (and Where It Bites)
Reflex keeps UI state on the server and pushes diffs over a WebSocket, so Python developers never touch JavaScript. Here's how the reactive model works, a runnable example, and where it strains.
30 Apr 2026, 04:39 UTC

If you've ever built a small internal dashboard in Python and then sighed at the prospect of wiring up fetch calls, loading spinners, and JSON serialization just to update a number on screen, Reflex's pitch is appealing: define state in Python, and the framework keeps the browser in sync for you. This post looks at how that actually works under the hood, with a concrete example, and where the model starts to strain.
State lives on the server, not the browser
In Reflex, you subclass rx.State and declare variables as class attributes. Any component that references one of those variables — directly, through rx.cond (conditional rendering), or rx.foreach (list rendering) — is automatically re-rendered when the value changes. You never write JavaScript, and you never write an API endpoint.
The mechanism is a persistent WebSocket connection between the browser and the Python backend. When an event handler mutates state, Reflex computes a diff — only the variables that actually changed — and pushes that diff to the client. Multiple mutations inside a single event handler are batched into one update per event loop tick, so a handler that touches five variables produces one network message, not five.
This is a meaningfully different model from typical React apps, where state lives in the browser and the server is a dumb JSON store. In Reflex, the server is the source of truth, and the UI is a projection of it.
A worked example: a shared counter
Here's a minimal app with two components reading the same state variable. Run this anywhere Reflex is installed (pip install reflex, then reflex init and reflex run); no special permissions needed beyond a local dev port.
import reflex as rx
class CounterState(rx.State):
count: int = 0
def increment(self):
self.count += 1
def counter_display() -> rx.Component:
return rx.heading(f"Count: {CounterState.count}")
def counter_badge() -> rx.Component:
return rx.badge(CounterState.count)
def index() -> rx.Component:
return rx.vstack(
counter_display(),
counter_badge(),
rx.button("Increment", on_click=CounterState.increment),
)
app = rx.App()
app.add_page(index)Click the button and both the heading and the badge update at once — that's the automatic dependency tracking. Reflex knows which components reference count and re-renders exactly those.
To verify the transport claim yourself, open your browser's devtools, go to the Network tab, filter by "WS" (WebSocket), and click the button. You should see a frame containing the state diff rather than a full page payload. I won't quote exact frame contents here since the format can change between releases — check it against your installed version.
Persisting state across reloads with @rx.persist
By default, state resets when the page reloads, because it lives in server memory tied to the session. Reflex offers @rx.persist (or the equivalent per-variable configuration, depending on version) to stash a variable in the browser's localStorage and rehydrate it on load:
class CounterState(rx.State):
count: int = 0 # persisted variant: see docs for your version's syntaxThe exact decorator syntax has shifted across Reflex releases, so treat the above as a pointer and confirm against the docs for the version you have installed (pip show reflex). The behavior to verify: set the counter to a non-zero value, reload the page, and confirm it survives; then clear site data in devtools and reload to see it reset.
The trade-offs you should know before committing
The server-authoritative model has real costs:
- Every interaction is a network round trip. The increment button above only feels instant on a low-latency connection. On a flaky network, UI updates lag. There's no optimistic client-side update unless you build one.
- High-frequency updates get expensive. Because diffs travel over the WebSocket, anything updating many times per second per client (live cursors, streaming telemetry at high resolution) will burn bandwidth and server CPU. Throttle or debounce those events rather than firing on every tick.
- Persisted state is localStorage. That means roughly 5 MB per origin, cleared whenever the user clears site data, and readable by any script on the page. Don't put anything large or sensitive there.
- Server memory scales with connected sessions. Each active client holds state server-side, which matters if you're used to stateless API backends.
When this model is the right call
Reflex's state system shines for internal tools, admin panels, prototypes, and data apps where the team is Python-native, latency is modest, and update frequency is human-paced. It's a poor fit for public-facing apps that need instant optimistic UI, offline tolerance, or high-frequency real-time collaboration.
If you're evaluating it, build the counter example above, watch the WebSocket frames, then simulate a slow network in devtools (Network tab → throttling) and click around. That five-minute experiment will tell you more about whether the model fits your app than any feature list will.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.