CRUD with ModelState¶
ModelState is the declarative version of the CRUD page you wrote in CRUD the manual way. You tell it what model to manage and which fields to expose; it generates the state variables, the load/save/delete handlers, the pagination, and the validation hooks for you.
This page walks through a complete product catalog page using ModelState, then explains every override you'll reach for: validation, scoping, search, custom hooks.
The smallest possible CRUD state¶
# shop/views.py
from reflex_django.state import ModelState
from shop.models import Product
class ProductState(ModelState):
model = Product
fields = ["name", "price", "sku", "is_active"]
ordering = ("-created_at",)
class Meta:
list_var = "products"
That's it. From those six lines you get, for free:
- A reactive list variable:
ProductState.products - Reactive form fields:
name,price,sku,is_active(with auto-generated setters:set_name, etc.) - Handlers:
load,save,create,delete,refresh,filter,paginate,cancel_edit - Tracking:
editing_id(the PK of the row being edited, or-1for "new"),form_reset_key,error
You write the UI; ModelState writes the wiring.
How it does it (briefly)¶
ModelState has a small metaclass that runs at class definition time — meaning the moment Python parses your class ProductState(ModelState): line, the following happens:
- Build a serializer. Either uses your
serializer_class, or auto-builds aReflexDjangoModelSerializerfrommodel+fields. - Declare state fields. For each entry in
fields, add a reactive variable to the class with the matching Python type (strforCharField,DecimalforDecimalField,boolforBooleanField, …) and an auto-generatedset_<field>setter. - Inject default handlers. If you didn't define
load,save, etc. yourself, the metaclass adds canonical implementations. - Register helper vars.
editing_id,form_reset_key,error, optional pagination/search fields.
Then at runtime, when an event fires, ModelState runs the handler through a small dispatch pipeline: bind the request context (so self.request.user works), run permission checks, run validation hooks, hit the database, update the reactive vars, send diff back to the browser.
You can hook into any stage of that pipeline by overriding methods. We'll see how below.
A complete product catalog page¶
1. The model¶
# shop/models.py
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=120)
sku = models.CharField(max_length=32, unique=True)
price = models.DecimalField(max_digits=10, decimal_places=2, default=0)
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})"
2. The state — about 8 lines¶
# shop/views.py
import reflex as rx
from reflex_django import template
from reflex_django.state import ModelState
from shop.models import Product
class ProductState(ModelState):
model = Product
fields = ["name", "price", "sku", "is_active"]
ordering = ("-created_at",)
search_fields = ("name", "sku") # enables full-text-ish search across these columns
class Meta:
list_var = "products" # generated list is `self.products`
reset_after_save = True # clear the form once a save succeeds
run_model_validation = True # call Django's full_clean() before save
structured_errors = True # populate `products_field_errors` for per-field UI
3. The UI — your job, but small¶
def field(label: str, input_: rx.Component, err: rx.Var) -> rx.Component:
return rx.vstack(
rx.text(label, size="2", weight="medium"),
input_,
rx.cond(err != "", rx.text(err, size="1", color="red")),
spacing="1",
)
def products_page() -> rx.Component:
errs = ProductState.products_field_errors
return rx.vstack(
rx.heading("Catalog"),
# toolbar
rx.hstack(
rx.input(
placeholder="Search…",
value=ProductState.search,
on_change=ProductState.set_search,
),
rx.button("Search", on_click=ProductState.refresh),
rx.button("Clear", on_click=ProductState.clear_filter, variant="outline"),
rx.spacer(),
rx.button("Add new", on_click=ProductState.create),
),
# form (only visible while creating/editing)
rx.cond(
ProductState.editing_id != -1,
rx.form(
rx.vstack(
field("Name", rx.input(value=ProductState.name, on_change=ProductState.set_name), errs["name"]),
field("SKU", rx.input(value=ProductState.sku, on_change=ProductState.set_sku), errs["sku"]),
field("Price", rx.input(value=ProductState.price, on_change=ProductState.set_price), errs["price"]),
rx.hstack(
rx.text("Active"),
rx.switch(checked=ProductState.is_active, on_change=ProductState.set_is_active),
),
rx.hstack(
rx.button("Save", on_click=ProductState.save),
rx.button("Cancel", on_click=ProductState.cancel_edit, variant="ghost"),
),
),
key=ProductState.form_reset_key, # remount on reset
),
),
# list
rx.foreach(ProductState.products, product_row),
spacing="4",
padding="2em",
)
def product_row(row: dict) -> rx.Component:
return rx.hstack(
rx.text(row["name"], weight="bold"),
rx.badge(row["sku"]),
rx.text(f"${row['price']}"),
rx.spacer(),
rx.button("Edit", on_click=ProductState.load(row["id"])),
rx.button("Delete", on_click=ProductState.delete(row["id"]), color_scheme="red"),
padding="0.5em",
border_bottom="1px solid rgba(0,0,0,0.08)",
)
@template(route="/products", title="Products", on_load=ProductState.refresh)
def index() -> rx.Component:
return products_page()
That's a working CRUD page in something like 80 lines of Python total, including the UI.
What you get for free¶
Once ProductState is defined, all of these are auto-generated. You can call them from your UI without writing them.
| Handler | What it does |
|---|---|
ProductState.refresh() |
Reload the list with current search/filter/page |
ProductState.create() |
Enter "new row" mode (sets editing_id = -1, clears the form) |
ProductState.load(pk) |
Enter "edit" mode for the row with this PK |
ProductState.save() |
Validate + create or update + reload |
ProductState.delete(pk) |
Delete the row + reload |
ProductState.cancel_edit() |
Leave edit mode, clear form |
ProductState.filter() |
Apply current search and reload |
ProductState.clear_filter() |
Clear search and reload |
ProductState.paginate(page) |
Jump to a specific page (if pagination is on) |
Variables you can bind in components:
| Variable | What it is |
|---|---|
ProductState.products |
List of dicts (the current page) |
ProductState.editing_id |
PK being edited, or -1 |
ProductState.error |
Top-level error message |
ProductState.search |
Current search query string |
ProductState.form_reset_key |
Bump this on the <form key=...> to remount and reset |
ProductState.products_field_errors |
Dict of {field: error_message} when structured_errors = True |
ProductState.name, ProductState.price, … |
One per entry in fields |
Pagination¶
Add one line to Meta:
That gives you four extra vars and three extra handlers:
| Var / handler | What it does |
|---|---|
ProductState.page |
Current page (1-indexed) |
ProductState.page_size |
Rows per page |
ProductState.total_count |
Total matching rows |
ProductState.page_count |
Total pages |
ProductState.next_page() |
Go to next page |
ProductState.prev_page() |
Go to previous page |
ProductState.paginate(page) |
Jump to a specific page |
Search¶
Add search_fields at the class level:
class ProductState(ModelState):
model = Product
fields = ["name", "price", "sku", "is_active"]
search_fields = ("name", "sku", "category__name") # ORM lookups OK
That gives you ProductState.search (a string) and the filter() handler. Search is case-insensitive icontains across the listed fields combined with OR.
For richer filtering, override filter_queryset (see below).
Validation¶
ModelState runs validation in three stages on every save:
clean_<field>(self, value)— per-field cleaning. Return the cleaned value or raiseValueError.validate_state(self)— cross-field checks on the in-memory state. Add issues toself.errororself.<list_var>_field_errors.run_model_validation— call Django'sModel.full_clean()on the instance. Django's ownvalidators=[...]andunique=Truechecks run here.
class ProductState(ModelState):
model = Product
fields = ["name", "price", "sku"]
class Meta:
list_var = "products"
run_model_validation = True
structured_errors = True
def clean_sku(self, value: str) -> str:
return value.strip().upper()
def clean_price(self, value):
try:
n = float(value)
except (TypeError, ValueError):
raise ValueError("Price must be a number")
if n < 0:
raise ValueError("Price can't be negative")
return value
def validate_state(self):
if self.is_active and not self.sku:
self.products_field_errors["sku"] = "Active products need a SKU"
Field-level errors land in <list_var>_field_errors. Bind them in your form (see the UI above).
User-scoped CRUD ("only show my rows")¶
This is the most common override. Two ways:
Way 1 — override get_queryset and friends¶
class TodoState(ModelState):
model = Todo
fields = ["title", "done"]
class Meta:
list_var = "todos"
def get_queryset(self):
return Todo.objects.filter(owner=self.request.user)
def get_object_lookup(self, pk: int) -> dict:
return {"pk": pk, "owner": self.request.user}
def get_create_kwargs(self, state_data: dict) -> dict:
return {**state_data, "owner": self.request.user}
Three hooks, one rule each:
get_queryset— what rows are visible in the list and lookupsget_object_lookup— how to find one row by ID (the ownership check on edit/delete)get_create_kwargs— extra fields injected when creating a new row
Way 2 — use UserScopedMixin¶
from reflex_django.mixins import UserScopedMixin
class TodoState(UserScopedMixin, ModelState):
model = Todo
fields = ["title", "done"]
class Meta:
list_var = "todos"
owner_field = "owner" # the FK field name on the model
The mixin does all three hooks for you. (More on mixins.)
Custom query filtering¶
Override filter_queryset to add filters beyond simple search:
class ProductState(ModelState):
model = Product
fields = ["name", "price", "category"]
search_fields = ("name",)
only_active: bool = True
def filter_queryset(self, qs):
qs = super().filter_queryset(qs)
if self.only_active:
qs = qs.filter(is_active=True)
return qs
Wire only_active to a toggle in the UI; bumping it re-triggers filter() and reloads.
Custom save logic¶
The default save does: validate → instantiate or fetch → set fields → asave() → reload. If you need different behavior, override:
class OrderState(ModelState):
model = Order
fields = ["customer", "amount", "status"]
class Meta:
list_var = "orders"
async def save(self):
# do something custom — then call the standard dispatch:
from reflex_django.state import ACTION_SAVE
await self.dispatch(ACTION_SAVE)
# ...post-save side effects, e.g. send an email
For finer control, you can also override before_save, after_save, before_delete, after_delete. The full list lives in the Mixins reference.
Read-only model lists¶
If you just want a list, no editing, use ModelListView (a subset of ModelState):
from reflex_django.state import ModelListView
class CatalogState(ModelListView):
model = Product
fields = ["name", "price", "sku"]
search_fields = ("name",)
class Meta:
list_var = "products"
paginate_by = 20
No form fields, no save/delete handlers. Just refresh, filter, paginate.
Refresh after each event automatically¶
If you want the list to refresh on every event the page processes (not just after saves), set:
For most apps, the explicit await self.refresh() after save/delete is enough.
When ModelState isn't the right fit¶
ModelState is best for "this page is mostly one model, with standard list + edit + delete". It struggles a bit with:
- Multi-model forms (one form that creates a
Userand aProfiletogether). - Wizards / multi-step flows.
- Pages where the "list" is computed from a complex aggregation.
- Anything where the page's primary job isn't CRUD.
For those, write plain AppState handlers as shown in CRUD the manual way. Mix freely — some pages can use ModelState, others can be hand-rolled.
If you specifically want explicit serializers (e.g. you're sharing a DRF schema), see ModelCRUDView with serializers.
Troubleshooting¶
| Symptom | Likely cause |
|---|---|
| Save silently succeeds but the list doesn't refresh | Forgot class Meta: list_var = "..." — the default is data. |
| Field errors not showing | structured_errors = True is required, and bind to <list_var>_field_errors. |
| Form doesn't clear after save | Set reset_after_save = True, and bump form_reset_key on the <form> element. |
validate_state runs but errors don't appear |
Check whether you assigned to self.error (global) or self.<list_var>_field_errors[field] (per-field). |
| Edits to one user's row affect others | You didn't scope. Override get_queryset / get_object_lookup / get_create_kwargs, or use UserScopedMixin. |
| Decimal/datetime serialization errors | The auto-built serializer handles those; if you provide a custom serializer_class, make sure datetime_format and decimal handling are configured. |
Next: ModelCRUDView with serializers → · Or compare side-by-side →