Backend

Lists and tables

A plain DRF ViewSet, the useList hook and DataTable: server side search, sorting, filters and pagination in a few lines.

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.

Mock backend120 products in the mock catalog

The backend

This is the catalog app of apps/backend-demo, unchanged. The model extends TimestampedModel (ObjectId key, created_at, updated_at):

demo/catalog/models.py
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.name

The 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.

demo/catalog/views.py
"""
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")()]
demo/catalog/urls.py
from rest_framework.routers import SimpleRouter

from .views import ProductViewSet

router = SimpleRouter(trailing_slash=False)
router.register("products", ProductViewSet, basename="product")

urlpatterns = router.urls
demo/urls.py
urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("pleate_backend.urls")),
    path("api/catalog/", include("demo.catalog.urls")),
]

The resulting API:

RequestEffect
GET /api/catalog/productsFirst 20 products, newest first.
?search=oatname, sku or category contains oat.
?ordering=-price,nameMost expensive first, then by name.
?status=active&category=PantryExact filters from the FilterSet Meta.
?min_price=5&max_price=20Range filters declared on the FilterSet.
?low_stock=trueA method filter: stock below 10.
?ordering=sku400 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:

ReturnedFor
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, paramsThe current state, to render anything else.
setPage, setPageSize, setSearch, setSort, setFilterChange the query from your own controls. All but setPage go back to page 1.

Options

OptionDefaultMeaning
pageSize20Items per page.
orderingnoneInitial ordering, e.g. -created_at.
filters{}Initial filters.
syncUrlfalseMirror page, search, ordering and filters in the address bar, so a list survives reload and can be shared.
searchDebounceMs300Debounce of searchProps (DataTable debounces on its own).
enabledtrueHold 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.

With Table, SearchBar and Pagination
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:

python
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)
tsx
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 for category and status.
  • Expose ids as strings: id = serializers.CharField(read_only=True).
  • Scope querysets to the user in get_queryset for private data; a foreign object should be a 404, not a 403.
  • Keep list serializers flat and use select_related; there is no prefetch_related on MongoDB.
  • Annotate with extend_schema so generated types stay precise.