Reflex occupies a different position than Streamlit or Gradio. It is not a dashboard tool or a model demo framework—it is a full-stack web framework that compiles Python to React and communicates state changes over WebSockets. The correct comparison is Next.js, SvelteKit, or Django with HTMX, not Streamlit.
This means the engineering concerns are also different: state graph design, event handler isolation, database integration, background tasks, and WebSocket-aware deployment matter in ways that do not apply to Streamlit or Gradio.
How Reflex state works
Reflex state is a Python class that inherits from rx.State. Fields on the class are reactive: when an event handler mutates a field, Reflex diffs the old and new state, serialises the delta, and pushes it to the browser over a WebSocket. The React frontend applies the delta without a full re-render.
class AppState(rx.State):
items: list[str] = []
loading: bool = False
async def add_item(self, item: str):
self.loading = True
yield # flush loading=True to the client
self.items.append(item)
self.loading = False
The yield inside an event handler is significant: it flushes the current state delta to the client without ending the handler. This enables progress indicators, streaming partial results, and any interaction that needs intermediate UI updates during a long operation.
State graph design: what goes where
The most common Reflex mistake is putting everything in a single state class. As the app grows, this creates a state object that is large (everything is serialised per connection), slow to diff (every event computes diffs over the entire state), and impossible to test in isolation.
The correct pattern: multiple state classes with explicit substates.
class AuthState(rx.State):
user_id: str = ""
user_role: str = ""
class DashboardState(AuthState):
data: list[dict] = []
filter_value: str = ""
class SettingsState(AuthState):
theme: str = "light"
notifications_enabled: bool = True
DashboardState inherits AuthState, so it has access to user_id and user_role without them being duplicated. Reflex serialises each substate independently and diffs only the affected substate on each event. A filter change in DashboardState does not force a diff of SettingsState.
Rules for state graph design:
- Put UI-only state at the component level if possible. Reflex 0.4+ supports local component state via
rx.ComponentState. Use it for toggle open/closed, tab selection, and any state that does not need to be shared. - Put shared state in the nearest common ancestor. If two components on different pages need the same value, it belongs in a shared parent state.
- Put backend-only values out of state entirely. Database connections, API clients, and model weights do not belong in
rx.State. They go in module-level singletons or dependency injection patterns.
Async events and background tasks
Reflex event handlers can be async. For operations that take more than a few milliseconds—database queries, API calls, model inference—async is mandatory:
async def load_data(self):
self.loading = True
yield
async with httpx.AsyncClient() as client:
response = await client.get(self.api_url)
self.data = response.json()
self.loading = False
For operations that should not block the UI thread at all—sending emails, running reports, processing uploads—use rx.background:
@rx.background
async def process_upload(self, file_data: bytes):
result = await heavy_processing(file_data)
async with self: # re-acquire state lock to mutate
self.result = result
self.processing = False
rx.background runs the handler in a background task. The async with self block re-acquires the state lock to safely mutate state from outside the normal event dispatch cycle. This is necessary because Reflex state is not thread-safe by default—all mutations must go through the state lock.
Database integration
Reflex ships with built-in SQLAlchemy integration via rx.Model. For simple CRUD applications this is convenient:
class User(rx.Model, table=True):
email: str
role: str = "viewer"
For anything beyond simple CRUD—complex queries, async drivers, connection pooling tuned to your load—bypass rx.Model and use SQLAlchemy directly with an async engine:
engine = create_async_engine(DATABASE_URL, pool_size=10, max_overflow=5)
async def get_users_by_role(role: str) -> list[dict]:
async with AsyncSession(engine) as session:
result = await session.execute(
select(User).where(User.role == role)
)
return [u.__dict__ for u in result.scalars()]
Call this from event handlers. The session is created and closed per call, which is correct for web request patterns. Do not store a session on rx.State—SQLAlchemy sessions are not serialisable.
WebSocket deployment
Reflex communicates via WebSocket. This has deployment implications that differ from a standard HTTP API:
Sticky sessions are required for multi-instance deployments. Each Reflex connection maintains server-side state tied to a specific process. A load balancer that round-robins requests between instances will break active connections. Configure cookie-based session affinity in nginx, AWS ALB, or GCP Cloud Run.
WebSocket timeout configuration. Default nginx proxy timeouts (60s) will terminate idle WebSocket connections. Set proxy_read_timeout to a value longer than your longest expected idle period (e.g., proxy_read_timeout 3600s; for applications where users may leave a tab open).
The frontend and backend deploy atomically. Reflex compiles the frontend at build time. A mismatch between the frontend JavaScript and the backend Python state protocol causes subtle bugs—state updates that appear to work but corrupt the UI. Your CI/CD pipeline must deploy both together, never independently.
Redis for state in multi-instance setups. By default, Reflex stores state in-process. For horizontal scaling, configure a Redis state backend. This moves state out of the process and allows multiple Reflex instances to share it—but it also adds serialisation overhead per event. Benchmark before assuming Redis makes multi-instance Reflex straightforward.
What Reflex is not designed for
- SEO-critical public pages. Reflex renders client-side. Search engine crawlers see a mostly-empty HTML shell. If organic search matters, put marketing pages on a static site or SSR framework and use Reflex for the authenticated application shell.
- High-frequency real-time data. WebSocket is the right transport, but Reflex’s state diff model is optimised for user-interaction frequency (clicks, form submissions), not sensor data or trading feeds that update hundreds of times per second.
- Large file uploads or downloads. Route these through a signed URL to S3/GCS rather than through the Reflex WebSocket connection.
Design Reflex state architecture that scales
We design state graphs, database integration, and deployment before complexity makes the app hard to change.