CRUD the manual way¶
reflex-django ships a declarative CRUD helper (ModelState) that generates list/save/delete handlers for you. But sometimes you want every step in front of you — for an unusual workflow, for clearer reading, or just because you like writing the code yourself.
This page walks through a complete user-scoped product inventory page using plain AppState and the async ORM. By the end you'll have list + search + pagination + create + edit + delete in one file you wrote line by line.
If you want the same thing in a third as much code, jump to CRUD with ModelState when you're done here.
What we're building¶
A page at /inventory that lets a logged-in user manage their own products. Each user only sees their own rows. Features:
- List with pagination
- Search by name / SKU / category
- Create new product
- Edit existing product (form switches to "edit mode")
- Delete with ownership check
Every database call uses the async ORM (acreate, aget, asave, adelete, async for).
1. The model¶
# inventory/models.py
from django.conf import settings
from django.db import models
class Product(models.Model):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="inventory_products",
)
name = models.CharField(max_length=128)
sku = models.CharField(max_length=64, unique=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
category = models.CharField(max_length=64, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.name} ({self.sku})"
Standard Django. owner is the per-user scope; everything else is regular fields.
Run the migration:
2. The serializer¶
Reflex state fields get JSON-encoded before they're shipped to the browser. Decimal and datetime aren't JSON-friendly, so we convert. A serializer is the easiest way:
# inventory/serializers.py
from reflex_django.serializers import ReflexDjangoModelSerializer
from inventory.models import Product
class ProductSerializer(ReflexDjangoModelSerializer):
class Meta:
model = Product
fields = ("id", "name", "sku", "price", "category", "is_active", "created_at")
read_only_fields = ("id", "created_at")
await ProductSerializer(qs, many=True).adata() turns a queryset into a JSON-safe list of dicts. More on serializers in Model serializers.
3. The state shell¶
We'll fill in the methods step by step. Here's the skeleton:
# inventory/views.py
import reflex as rx
from django.db.models import Q
from reflex_django.state import AppState
from inventory.models import Product
from inventory.serializers import ProductSerializer
class InventoryState(AppState):
# what the UI renders
products: list[dict] = []
error: str = ""
# form bindings
name: str = ""
sku: str = ""
price: str = ""
category: str = ""
is_active: bool = True
# which row are we editing? -1 = creating new
editing_id: int = -1
# search and pagination
search_query: str = ""
page: int = 1
page_size: int = 8
total_pages: int = 1
InventoryState(AppState) is what gives us self.request.user later. The fields are reactive — assigning to them on the server updates the browser automatically.
4. Build the queryset (scoped, searched, paginated)¶
This helper centralizes the "scope to current user + apply search" logic. Every other method calls it, so we only get the scope right once:
def _filtered_qs(self):
user = self.request.user
if not user.is_authenticated:
raise PermissionError("Sign in first.")
qs = Product.objects.filter(owner=user)
q = self.search_query.strip()
if q:
qs = qs.filter(
Q(name__icontains=q)
| Q(sku__icontains=q)
| Q(category__icontains=q)
)
return qs.order_by("-created_at")
filter(owner=user) is the security boundary. As long as every read/write goes through this queryset, a user can't touch another user's rows even by forging IDs.
5. Load and paginate¶
@rx.event
async def load(self):
self.error = ""
try:
qs = self._filtered_qs()
total = await qs.acount()
self.total_pages = max(1, (total + self.page_size - 1) // self.page_size)
self.page = min(self.page, self.total_pages)
start = (self.page - 1) * self.page_size
page_qs = qs[start : start + self.page_size]
self.products = await ProductSerializer(page_qs, many=True).adata()
except PermissionError as e:
self.error = str(e)
self.products = []
except Exception as e:
self.error = f"Couldn't load: {e}"
Two small details:
await qs.acount()is the async row count. Never callqs.count()in an async handler.- The slice
qs[start : start + page_size]becomes aLIMIT/OFFSETin SQL. It doesn't materialize the whole table.
Wire up search and page navigation:
@rx.event
async def set_search(self, value: str):
self.search_query = value
self.page = 1
await self.load()
@rx.event
async def next_page(self):
if self.page < self.total_pages:
self.page += 1
await self.load()
@rx.event
async def prev_page(self):
if self.page > 1:
self.page -= 1
await self.load()
6. Create / update — one handler for both¶
def _validation_error(self) -> str | None:
if not self.name.strip():
return "Name is required."
if not self.sku.strip():
return "SKU is required."
try:
if float(self.price) <= 0:
return "Price must be positive."
except ValueError:
return "Price must be a number."
return None
@rx.event
async def save(self):
self.error = ""
user = self.request.user
if not user.is_authenticated:
return rx.toast.error("Please log in.")
err = self._validation_error()
if err:
self.error = err
return
data = {
"name": self.name.strip(),
"sku": self.sku.strip().upper(),
"price": self.price,
"category": self.category.strip(),
"is_active": self.is_active,
}
try:
if self.editing_id >= 0:
# update — fetch with owner scope so users can't edit foreign rows
product = await Product.objects.aget(pk=self.editing_id, owner=user)
for k, v in data.items():
setattr(product, k, v)
await product.asave()
yield rx.toast.success(f"Updated '{product.name}'.")
else:
# create — owner is always the current user
new = await Product.objects.acreate(owner=user, **data)
yield rx.toast.success(f"Added '{new.name}'.")
self.reset_form()
await self.load()
except Exception as e:
self.error = f"Save failed: {e}"
@rx.event
def reset_form(self):
self.name = ""
self.sku = ""
self.price = ""
self.category = ""
self.is_active = True
self.editing_id = -1
self.error = ""
The important bit: when we update, we fetch with aget(pk=..., owner=user). If the user passes an ID they don't own, aget raises DoesNotExist and the row stays safe. Same on the create path — the owner is user, period; we never trust the client to tell us who owns the row.
7. Edit and delete¶
@rx.event
async def start_editing(self, product_id: int):
try:
p = await Product.objects.aget(pk=product_id, owner=self.request.user)
except Product.DoesNotExist:
return rx.toast.error("Not found.")
self.editing_id = p.id
self.name = p.name
self.sku = p.sku
self.price = str(p.price)
self.category = p.category
self.is_active = p.is_active
@rx.event
async def delete(self, product_id: int):
try:
p = await Product.objects.aget(pk=product_id, owner=self.request.user)
name = p.name
await p.adelete()
yield rx.toast.success(f"Deleted '{name}'.")
await self.load()
except Product.DoesNotExist:
return rx.toast.error("Not found.")
Same ownership check pattern as before. Notice that self.price = str(p.price) — the form field is a string, but the DB value is Decimal. Convert at the boundary.
8. The UI¶
def inventory_page() -> rx.Component:
return rx.container(
rx.heading("My Inventory", size="8"),
rx.cond(
InventoryState.error != "",
rx.callout(InventoryState.error, color_scheme="red"),
),
rx.grid(
form_card(),
list_card(),
columns="2",
spacing="6",
),
padding="2rem",
)
def form_card() -> rx.Component:
return rx.card(
rx.vstack(
rx.heading(
rx.cond(InventoryState.editing_id >= 0, "Edit product", "Add product"),
size="4",
),
rx.input(placeholder="Name", value=InventoryState.name, on_change=InventoryState.set_name),
rx.input(placeholder="SKU", value=InventoryState.sku, on_change=InventoryState.set_sku),
rx.input(placeholder="Price", value=InventoryState.price, on_change=InventoryState.set_price),
rx.input(placeholder="Category", value=InventoryState.category, on_change=InventoryState.set_category),
rx.hstack(
rx.text("Active"),
rx.switch(checked=InventoryState.is_active, on_change=InventoryState.set_is_active),
),
rx.hstack(
rx.button("Save", on_click=InventoryState.save),
rx.cond(
InventoryState.editing_id >= 0,
rx.button("Cancel", on_click=InventoryState.reset_form, variant="ghost"),
),
),
spacing="3",
),
padding="1.5rem",
)
def list_card() -> rx.Component:
return rx.vstack(
rx.input(
placeholder="Search by name, SKU, or category…",
value=InventoryState.search_query,
on_change=InventoryState.set_search,
),
rx.foreach(InventoryState.products, product_row),
rx.hstack(
rx.button("Previous", on_click=InventoryState.prev_page, disabled=InventoryState.page == 1),
rx.text(f"Page {InventoryState.page} of {InventoryState.total_pages}"),
rx.button("Next", on_click=InventoryState.next_page, disabled=InventoryState.page == InventoryState.total_pages),
justify="between",
),
spacing="3",
)
def product_row(row: dict) -> rx.Component:
return rx.hstack(
rx.vstack(
rx.text(row["name"], weight="bold"),
rx.hstack(rx.badge(row["sku"]), rx.text(f"${row['price']}")),
align_items="start",
),
rx.spacer(),
rx.button("Edit", on_click=InventoryState.start_editing(row["id"]), variant="surface"),
rx.button("Delete", on_click=InventoryState.delete(row["id"]), color_scheme="red", variant="ghost"),
padding="0.75rem",
border_bottom="1px solid rgba(0,0,0,0.08)",
)
Register the page with @template:
from reflex_django import template
@template(route="/inventory", title="Inventory", on_load=InventoryState.load)
def index() -> rx.Component:
return inventory_page()
That's the whole feature — list, search, paginate, create, edit, delete — in a single views.py file.
Why you might prefer this style¶
- Total visibility. Every query, every check, every error path is right there. No hooks to override.
- Custom workflows. Multi-step forms, conditional fields, weird business rules — they all fit naturally.
- Easy to learn. It's just async Python and the Django ORM. Anyone who's seen Django can read it.
And why you might prefer ModelState¶
- Far less code. The same feature can be ~25 lines instead of ~150.
- Less to maintain. Sensible defaults handle pagination, validation, scoping.
- Consistent UX. All your CRUD pages behave the same way.
Both styles work in the same project. Use the manual style when the page is unusual, and ModelState when it's standard. See CRUD with ModelState for the declarative version.
Manual vs declarative cheat sheet¶
| You write | Manual | ModelState |
|---|---|---|
The load handler |
Yes | Generated |
The save handler |
Yes | Generated |
The delete handler |
Yes | Generated |
| Per-field state vars | Yes | Generated from fields = [...] |
| Pagination | Yes | paginate_by = X in Meta |
| Owner scoping | Yes (_filtered_qs) |
UserScopedMixin or override get_queryset |
| Custom validation | Yes | clean_<field> or validate_state |
| The UI components | Yes | Yes (still your job) |
Next: CRUD with ModelState →