Backend
Lists and tables
Most screens of an app are lists. pleate-backend makes every list endpoint speak the same query language, and useList turns that language into the props of DataTable, Table, SearchBar and Pagination. Adding a resource of your own takes a model, a plain DRF ViewSet and a route.
Products, server side
Search, sort by a column, filter by status or low stock, change page: each change is one request to /api/catalog/products.
The backend
This is the catalog app of apps/backend-demo, unchanged. The model extends TimestampedModel (ObjectId key, created_at, updated_at):
from django.db import models
from pleate_backend.core.models import TimestampedModel
class Product(TimestampedModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DRAFT = "draft", "Draft"
ARCHIVED = "archived", "Archived"
name = models.CharField(max_length=200)
sku = models.CharField(max_length=40, unique=True)
category = models.CharField(max_length=60, db_index=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
stock = models.PositiveIntegerField(default=0)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.ACTIVE, db_index=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.nameThe view only declares what is allowed. Pagination (PagePagination), search, ordering and field filters come from the defaults in REST_FRAMEWORK; reads need a session or key, writes need catalog.change_product.
"""
A plain DRF ViewSet. Pagination, ``search``, ``ordering`` and field filters
come from the pleate-backend defaults, the view only declares what is
allowed. This is all a DataTable needs.
"""
import django_filters
from rest_framework import serializers, viewsets
from rest_framework.permissions import IsAuthenticated
from pleate_backend.rbac.permissions import HasPerm
from .models import Product
class ProductSerializer(serializers.ModelSerializer):
id = serializers.CharField(read_only=True)
class Meta:
model = Product
fields = ["id", "name", "sku", "category", "price", "stock", "status", "created_at", "updated_at"]
class ProductFilter(django_filters.FilterSet):
min_price = django_filters.NumberFilter(field_name="price", lookup_expr="gte")
max_price = django_filters.NumberFilter(field_name="price", lookup_expr="lte")
low_stock = django_filters.BooleanFilter(method="filter_low_stock")
class Meta:
model = Product
fields = ["status", "category"]
def filter_low_stock(self, queryset, name, value):
return queryset.filter(stock__lt=10) if value else queryset
class ProductViewSet(viewsets.ModelViewSet):
queryset = Product.objects.all()
serializer_class = ProductSerializer
filterset_class = ProductFilter
search_fields = ["name", "sku", "category"]
ordering_fields = ["name", "price", "stock", "created_at"]
ordering = ["-created_at"]
def get_permissions(self):
# Everyone signed in can read, writing needs the model permission.
if self.request.method in ("GET", "HEAD", "OPTIONS"):
return [IsAuthenticated()]
return [IsAuthenticated(), HasPerm("catalog.change_product")()]from rest_framework.routers import SimpleRouter
from .views import ProductViewSet
router = SimpleRouter(trailing_slash=False)
router.register("products", ProductViewSet, basename="product")
urlpatterns = router.urlsurlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("pleate_backend.urls")),
path("api/catalog/", include("demo.catalog.urls")),
]The resulting API:
| Request | Effect |
|---|---|
| GET /api/catalog/products | First 20 products, newest first. |
| ?search=oat | name, sku or category contains oat. |
| ?ordering=-price,name | Most expensive first, then by name. |
| ?status=active&category=Pantry | Exact filters from the FilterSet Meta. |
| ?min_price=5&max_price=20 | Range filters declared on the FilterSet. |
| ?low_stock=true | A method filter: stock below 10. |
| ?ordering=sku | 400 invalid_ordering: sku is not in ordering_fields. |
The frontend
useList(path, options) keeps page, page size, search, ordering and filters in state, fetches with TanStack Query (the previous page stays on screen while the next loads) and returns ready made props:
| Returned | For |
|---|---|
| tableProps | <DataTable>: rows, loading, sort, search, page, pageCount, total, pageSize and their handlers. |
| paginationProps | <Pagination> |
| searchProps | <SearchBar>, debounced by searchDebounceMs (300 ms). |
| sortProps(key) | A sortable <TableHead> of the plain Table. |
| rows, total, page, pageCount, filters, params | The current state, to render anything else. |
| setPage, setPageSize, setSearch, setSort, setFilter | Change the query from your own controls. All but setPage go back to page 1. |
Options
| Option | Default | Meaning |
|---|---|---|
| pageSize | 20 | Items per page. |
| ordering | none | Initial ordering, e.g. -created_at. |
| filters | {} | Initial filters. |
| syncUrl | false | Mirror page, search, ordering and filters in the address bar, so a list survives reload and can be shared. |
| searchDebounceMs | 300 | Debounce of searchProps (DataTable debounces on its own). |
| enabled | true | Hold the request, e.g. until a parent id is known. |
The first argument can also be a function (params) => Promise<Page<T>> when the endpoint needs extra parameters; useConversations is built this way.
const list = useList<Product>("/catalog/products", { syncUrl: true });
<SearchBar {...list.searchProps} placeholder="Search products" />
<Table>
<TableHeader>
<TableRow>
<TableHead {...list.sortProps("name")}>Name</TableHead>
<TableHead {...list.sortProps("price")}>Price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.rows.map((p) => (
<TableRow key={p.id}><TableCell>{p.name}</TableCell><TableCell>{p.price}</TableCell></TableRow>
))}
</TableBody>
</Table>
<Pagination {...list.paginationProps} />Feeds
For lists that grow at the top (notifications, activity, chat), use CursorPagination on the view and useCursorList on the client. It returns TanStack's infinite query plus items, the pages flattened:
from pleate_backend.core.pagination import CursorPagination
class OrderFeed(generics.ListAPIView):
pagination_class = CursorPagination # newest first by created_at
serializer_class = OrderSerializer
def get_queryset(self):
return Order.objects.filter(user=self.request.user)const client = usePleateClient();
const feed = useCursorList(["orders"], (p) => client.http.get<CursorPage<Order>>("/orders", p), { pageSize: 30 });
<List>{feed.items.map((o) => <ListItem key={o.id}>{o.number}</ListItem>)}</List>
{feed.hasNextPage && <Button onClick={() => feed.fetchNextPage()}>Load more</Button>}Good practice
- Index what you filter and sort on (
db_index=True), as the catalog does forcategoryandstatus. - Expose ids as strings:
id = serializers.CharField(read_only=True). - Scope querysets to the user in
get_querysetfor private data; a foreign object should be a 404, not a 403. - Keep list serializers flat and use
select_related; there is noprefetch_relatedon MongoDB. - Annotate with
extend_schemaso generated types stay precise.