Reflex: Building Full-Stack Web Apps in Pure Python
Reflex lets you build complete web applications — frontend, backend, and database — without leaving Python. Unlike dashboard-oriented tools, Reflex compiles your Python components into a React/Next.js frontend while running your application logic on a FastAPI backend, giving you genuine state management, routing, and persistence. This tutorial walks through the architecture, the state model, components, routing, async handlers, database access, and deployment using a coherent task-manager example.
What Reflex Is and How It Works
Reflex (formerly Pynecone) is a framework for building full-stack web apps where you write only Python. At build time, the components you describe in Python are compiled into a React/Next.js frontend. At runtime, your application state lives in Python classes that execute on a FastAPI backend. The browser and the backend communicate over a WebSocket connection: user interactions trigger event handlers on the server, the server mutates state, and only the changed state is pushed back to the client, which re-renders the affected components.
This is a different model from Streamlit. In Streamlit, every interaction re-runs your entire script top to bottom, and you manage persistence through st.sessionstate. Reflex instead keeps a long-lived state object per client session and updates the UI reactively — only the parts bound to changed state vars update. You get a single-page-application feel with client-side routing, plus a real backend you can attach databases and background tasks to.
Architecture at a Glance
- Frontend: Python components compile to React components rendered by Next.js. Styling maps to CSS/Tailwind under the hood.
- Backend: A FastAPI server hosts your
Stateclasses and event handlers. - Transport: A WebSocket carries events from client to server and state deltas back.
- Database (optional): Built-in SQLModel integration (SQLite by default) accessible from event handlers.
Browser (React/Next.js) <-- WebSocket --> FastAPI backend (Python State + handlers)
|
Database (SQLModel)
Installation and Project Setup
Reflex requires Python 3.10 or newer. Create an isolated environment, then install the package.
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install reflex
Verify the installation
reflex --version
Initialize a new project inside an empty directory. The init command scaffolds the structure and prompts you to pick a template; choose the blank template to start clean.
mkdir taskmanager && cd taskmanager
reflex init
When prompted, select the "blank" template
Project Structure
After initialization you get a layout similar to this:
taskmanager/
├── taskmanager/
│ └── taskmanager.py # Your app: state, components, pages
├── assets/ # Static files (images, favicon)
├── rxconfig.py # App configuration (name, dburl, etc.)
└── requirements.txt
The rxconfig.py file declares the app name and configuration such as the database URL and frontend/backend ports.
import reflex as rx
config = rx.Config(
appname="taskmanager",
dburl="sqlite:///taskmanager.db",
)
Running in Development
Start the development server. Reflex compiles the frontend, launches the FastAPI backend, and serves the app with hot reload.
reflex run
Frontend on http://localhost:3000, backend on http://localhost:8000
The State Model
State is the core of Reflex. You define state by subclassing rx.State. Class attributes with type annotations become state vars — reactive values stored per client session. Methods on the state class are event handlers: they run on the backend and are the only place you should mutate state.
import reflex as rx
class CounterState(rx.State):
count: int = 0
def increment(self):
self.count += 1
def decrement(self):
self.count -= 1
def setcount(self, value: str):
# Event handlers receive event payloads as arguments
self.count = int(value or 0)
When increment runs, it mutates self.count. Reflex computes the delta and pushes it to the client, which re-renders only the components bound to count. There is no full-script rerun: unrelated parts of the page are untouched, and local component state on the frontend (scroll position, focus) is preserved.
Wiring State to Components
You build the UI by returning components from a function and binding their props and events to the state.
def counter() -> rx.Component:
return rx.hstack(
rx.button("-", onclick=CounterState.decrement),
rx.text(CounterState.count),
rx.button("+", onclick=CounterState.increment),
spacing="3",
align="center",
)
CounterState.count is not a plain integer here — it is a reactive reference. When the value changes on the backend, the rx.text updates automatically. Events like onclick are bound to handler references (not called), and Reflex invokes them on the server when triggered.
Building UI From Components
Reflex ships a large set of components. Layout primitives such as rx.vstack, rx.hstack, and rx.box arrange children; content components such as rx.text, rx.heading, rx.input, and rx.button render UI. Props are passed as keyword arguments.
def taskinput() -> rx.Component:
return rx.vstack(
rx.heading("My Tasks", size="6"),
rx.hstack(
rx.input(
placeholder="What needs doing?",
value=TaskState.new
task,
onchange=TaskState.setnewtask,
width="100%",
),
rx.button("Add", onclick=TaskState.addtask),
width="100%",
),
rx.box(height="1rem"),
spacing="3",
width="100%",
maxwidth="480px",
)
Here the input's value is bound to a state var and onchange updates it on every keystroke. This two-way binding is explicit: the displayed value always reflects the backend state, and edits flow back through the event handler.
Computed Vars and Conditional/Iterative Rendering
A computed var is a property decorated with @rx.var that derives a value from other state vars. It recomputes automatically when its dependencies change, which keeps derived data out of your event handlers.
class TaskState(rx.State):
tasks: list[dict] = []
newtask: str = ""
@rx.var
def opencount(self) -> int:
return len([t for t in self.tasks if not t["done"]])
@rx.var
def hastasks(self) -> bool:
return len(self.tasks) > 0
Because the UI is compiled, you cannot use ordinary Python if/for over state vars inside components — at compile time the actual values are unknown. Instead, use rx.cond for conditional rendering and rx.foreach for iteration.
def taskrow(task: dict, index: int) -> rx.Component:
return rx.hstack(
rx.checkbox(
checked=task["done"],
on
change=lambda checked: TaskState.toggle(index),
),
rx.text(task["title"]),
rx.spacer(),
rx.button(
"Delete",
onclick=lambda: TaskState.delete(index),
colorscheme="red",
variant="soft",
),
width="100%",
align="center",
)
def tasklist() -> rx.Component:
return rx.cond(
TaskState.hastasks,
rx.vstack(
rx.foreach(TaskState.tasks, taskrow),
width="100%",
),
rx.text("No tasks yet. Add one above.", color="gray"),
)
rx.cond(condition, whentrue, whenfalse) renders one branch based on a reactive condition. rx.foreach(iterable, renderfn) maps each item (and optional index) to a component; the render function must be defined at module level because it is compiled, not executed per render.
Multi-Page Routing
Reflex apps are multi-page by default. You register pages either with the @rx.page decorator or by calling app.addpage. Each page maps to a route.
app = rx.App()
@rx.page(route="/", title="Tasks")
def index() -> rx.Component:
return rx.container(taskinput(), tasklist())
def about() -> rx.Component:
return rx.container(
rx.heading("About"),
rx.text("A small task manager built with Reflex."),
rx.link("Back to tasks", href="/"),
)
app.addpage(about, route="/about", title="About")
Dynamic Routes and Redirects
Dynamic segments are declared with square brackets in the route. The value is available through a router state var.
class DetailState(rx.State):
@rx.var
def taskid(self) -> str:
return self.router.page.params.get("id", "")
@rx.page(route="/task/[id]")
def taskdetail() -> rx.Component:
return rx.container(
rx.heading("Task detail"),
rx.text("Viewing task: ", DetailState.taskid),
rx.link("Back", href="/"),
)
To navigate programmatically from an event handler, return rx.redirect.
def saveandexit(self):
# ... persist changes ...
return rx.redirect("/")
Styling, Themes, and Responsive Props
You can style components inline with prop names that map to CSS, pass a style dict, or define a global theme on the app. Reflex uses Tailwind under the hood and integrates with the Radix-based component library for consistent theming.
app = rx.App(
theme=rx.theme(
appearance="light",
accentcolor="indigo",
radius="medium",
),
)
Style dicts and responsive props let you adapt to screen size. Pass a list to a prop to set values per breakpoint (mobile-first).
def card(content: rx.Component) -> rx.Component:
return rx.box(
content,
style={
"padding": "1.5rem",
"border": "1px solid var(--gray-5)",
"borderradius": "12px",
},
width=["100%", "100%", "480px"], # full width on small, fixed on large
)
Async Event Handlers and Streaming Updates
Event handlers can be async, which is essential for calling external APIs or models without blocking. By yielding inside a handler, you push intermediate state to the client, producing progressive UI updates — useful for streaming model output.
import asyncio
import reflex as rx
class ChatState(rx.State):
prompt: str = ""
answer: str = ""
isstreaming: bool = False
async def ask(self):
self.isstreaming = True
self.answer = ""
yield # flush "streaming started" to the client
# Simulate token-by-token streaming from a model/API
async for chunk in fakemodelstream(self.prompt):
self.answer += chunk
yield # push each partial answer to the UI
self.isstreaming = False
async def fakemodelstream(prompt: str):
for word in f"You asked: {prompt}. Here is a streamed reply.".split():
await asyncio.sleep(0.05)
yield word + " "
Each yield sends the current state delta over the WebSocket. The UI bound to ChatState.answer updates incrementally, and you can bind a spinner to isstreaming.
def chatview() -> rx.Component:
return rx.vstack(
rx.input(
value=ChatState.prompt,
onchange=ChatState.setprompt,
placeholder="Ask something...",
),
rx.button("Send", onclick=ChatState.ask, loading=ChatState.isstreaming),
rx.text(ChatState.answer),
width="100%",
)
Forms with Client- and Server-Side Validation
rx.form collects fields and submits their values as a dict to a handler. Use HTML-level constraints (required, type, minlength) for client-side checks, and validate again on the server before persisting.
class FormState(rx.State):
error: str = ""
def submit(self, formdata: dict):
title = (formdata.get("title") or "").strip()
if len(title) < 3:
self.error = "Title must be at least 3 characters."
return
self.error = ""
return TaskState.addtaskfromform(title)
def taskform() -> rx.Component:
return rx.form(
rx.vstack(
rx.input(name="title", placeholder="Task title", required=True),
rx.cond(
FormState.error != "",
rx.text(FormState.error, color="red", size="2"),
),
rx.button("Create", type="submit"),
),
onsubmit=FormState.submit,
resetonsubmit=True,
)
Client-side required blocks empty submits in the browser; the server-side length check is authoritative because clients can bypass HTML validation. Always treat server validation as the source of truth.
Database Access with rx.Model
Reflex includes SQLModel integration. Subclass rx.Model with table=True to define a table. You query and mutate inside event handlers using a session via rx.session().
import reflex as rx
from sqlmodel import select
class Task(rx.Model, table=True):
title: str
done: bool = False
Create the schema by generating and applying a migration (Reflex wraps Alembic).
reflex db init # one time, sets up migrations
reflex db makemigrations --message "add task table"
reflex db migrate
Now read and write inside handlers. Load persisted rows into a state var so the UI can render them.
class TaskState(rx.State):
tasks: list[Task] = []
newtask: str = ""
def loadtasks(self):
with rx.session() as session:
self.tasks = session.exec(select(Task)).all()
def addtask(self):
title = self.newtask.strip()
if not title:
return
with rx.session() as session:
session.add(Task(title=title))
session.commit()
self.newtask = ""
self.loadtasks()
def toggle(self, taskid: int):
with rx.session() as session:
task = session.get(Task, taskid)
if task:
task.done = not task.done
session.add(task)
session.commit()
self.loadtasks()
Call loadtasks when the page loads by attaching it to the page's onload.
@rx.page(route="/", onload=TaskState.loadtasks)
def index() -> rx.Component:
return rx.container(task
input(), tasklist())
Background Tasks
For long-running work that should not block the event loop or hold the state lock, declare a background event with @rx.event(background=True). Inside it, you must mutate state within an async with self block to acquire the lock safely.
class JobState(rx.State):
progress: int = 0
@rx.event(background=True)
async def runjob(self):
for i in range(1, 101):
await asyncio.sleep(0.1)
async with self:
self.progress = i
This keeps the UI responsive while the job runs and streams progress updates as the loop advances.
Building and Deploying
For local production-style builds, export the frontend and backend artifacts.
# Produce a static frontend and a backend bundle
reflex export
Reflex offers a hosted deploy command that provisions and ships the app.
reflex deploy
For self-hosting, containerize the app. A minimal Dockerfile installs dependencies, runs migrations, and starts the production server.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN reflex export --frontend-only --no-zip
EXPOSE 3000 8000
CMD ["reflex", "run", "--env", "prod"]
Behind a reverse proxy (nginx/Caddy), route the frontend port and proxy the WebSocket/backend path so client and server connect correctly.
Best Practices
- Keep state minimal and serializable. State vars cross the WebSocket as JSON; store plain data and load heavy objects on demand inside handlers.
- Mutate state only in event handlers. Treat components as a pure view of state; never mutate state during rendering.
- Use computed vars for derived data. Avoid duplicating logic across handlers; let
@rx.varrecompute automatically. - Split state by feature. Multiple
rx.Statesubclasses keep concerns isolated and reduce unnecessary updates. - Validate on the server. Client-side constraints improve UX, but the backend must enforce correctness before writing to the database.
- Reload from the database after writes. After a commit, refresh the relevant state var so the UI reflects the persisted truth.
- Use async handlers for I/O and background events for long jobs. Yield to stream progress and keep the interface responsive.
- Pin the Reflex version in
requirements.txtso compiled output stays reproducible across environments.
Conclusion and Key Takeaways
Reflex closes the gap between quick Python prototypes and real full-stack applications. By compiling Python components to a React/Next.js frontend and running your state on a FastAPI backend, it gives you reactive UI updates, client-side routing, forms, and database persistence without writing JavaScript.
Key takeaways:
- Reflex is full-stack: Python components compile to a React frontend;
Stateruns on a FastAPI backend over WebSocket. - The reactive state model updates only changed components, in contrast to Streamlit's full top-to-bottom rerun.
- Build UIs from components, bind props to state vars, and wire events to handlers; use
rx.condandrx.foreachfor dynamic rendering. @rx.varcomputed vars derive data; multi-page routing, dynamic routes, andrx.redirecthandle navigation.- Async handlers with
yieldstream progressive updates;rx.Modelprovides built-in database access; background events handle long-running work. - Ship with
reflex export,reflex deploy, or a Docker container behind a reverse proxy.
With these building blocks you can grow a single file into a maintainable, persistent, multi-page application — all in pure Python.