Server-side data with Django
This tutorial shows how to wire Handsontable’s dataProvider plugin to a Django REST Framework (DRF) backend. The backend handles pagination, sorting, and filtering on the server. The frontend displays results and sends all edits back to the API.
Difficulty: Intermediate
Time: ~30 minutes
Backend: Python 3.11+, Django 4+, Django REST Framework 3.14+
What you’ll build
An employee directory grid that:
- Loads data page by page from a DRF API
- Sorts rows by any column on the server
- Filters rows by column value on the server
- Creates, updates, and deletes rows via batch API endpoints
- Handles Django’s CSRF protection transparently
Before you begin
- Docker and Docker Compose installed
- Node.js and npm installed
No local Python installation is required — the Django backend and PostgreSQL database run inside Docker.
Step 1 — Set up the Django app
Start the backend and the Vite dev server with bash setup.sh (or make setup). The script builds and starts PostgreSQL + Django via Docker Compose, runs migrations, seeds 50 employee records, and opens the Vite dev server at http://localhost:5173.
The Django project uses a employees app:
Why a separate app?
Django apps are self-contained modules. Keeping the employee model, serializer, and views in one app makes the code easier to extend and test independently.
Register the app and required packages in settings.py:
# settings.py -- relevant additions only.# Add these to your existing Django settings file.
INSTALLED_APPS = [ # ... your existing apps ... "rest_framework", "corsheaders", # django-cors-headers "django_filters", # django-filter (optional, for DjangoFilterBackend) "employees", # your app]
MIDDLEWARE = [ # CorsMiddleware must come before CommonMiddleware. "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", # ... rest of your middleware ...]
# --- CORS ---# Allow requests from the frontend dev server.# In production, restrict this to your actual domain(s).CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", # Vite dev server "http://localhost:3000", # Create React App / Next.js dev server "https://your-production-domain.com",]
# If your frontend sends credentials (cookies, Authorization headers), also set:# CORS_ALLOW_CREDENTIALS = True
# --- Django REST Framework ---REST_FRAMEWORK = { "DEFAULT_PAGINATION_CLASS": "employees.pagination.EmployeePagination", "PAGE_SIZE": 10, "DEFAULT_FILTER_BACKENDS": [ "rest_framework.filters.OrderingFilter", "rest_framework.filters.SearchFilter", # Uncomment to add DjangoFilterBackend for exact field matching: # "django_filters.rest_framework.DjangoFilterBackend", ],}Step 2 — Define the Employee model
Create the model in employees/models.py:
from django.db import models
class Employee(models.Model): """ Employee model for the HR directory demo.
Fields match the column definitions used in the Handsontable frontend: id is generated automatically by Django and used as rowId. """
DEPARTMENT_CHOICES = [ ("Engineering", "Engineering"), ("Marketing", "Marketing"), ("Sales", "Sales"), ("HR", "HR"), ("Finance", "Finance"), ("Operations", "Operations"), ]
first_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100) department = models.CharField(max_length=100, choices=DEPARTMENT_CHOICES) role = models.CharField(max_length=100) # DecimalField avoids floating-point rounding for currency values. salary = models.DecimalField(max_digits=10, decimal_places=2)
class Meta: # Default ordering by last name keeps the initial grid display predictable. ordering = ["last_name", "first_name"]
def __str__(self): return f"{self.first_name} {self.last_name} ({self.department})"What’s happening:
DecimalFieldstores salary without floating-point rounding errors — important for currency values.orderinginMetasets the default query order. TheOrderingFilteroverrides it when the user sorts a column.- Django automatically adds an
idprimary key. This becomes therowIdvalue on the frontend.
Run migrations to create the database table:
python manage.py makemigrations employeespython manage.py migrateStep 3 — Seed the database
Create the seed command file at employees/management/commands/seed.py:
# employees/management/commands/seed.py# Run with: python manage.py seed# Place this file at:# employees/management/__init__.py (empty)# employees/management/commands/__init__.py (empty)# employees/management/commands/seed.py (this file)
from django.core.management.base import BaseCommandfrom employees.models import Employee
SEED_DATA = [ ("Ana", "García", "Engineering", "Senior Engineer", 95000), ("James", "Okafor", "Engineering", "Backend Developer", 87000), ("Li", "Wei", "Marketing", "Campaign Manager", 72000), ("Sara", "Müller", "HR", "HR Business Partner", 68000), ("Carlos", "Rivera", "Sales", "Account Executive", 76000), ("Priya", "Nair", "Finance", "Financial Analyst", 80000), ("Tom", "Nielsen", "Engineering", "Frontend Developer", 84000), ("Amara", "Diallo", "Operations", "Operations Manager", 91000), ("Sophie", "Dupont", "Marketing", "Content Strategist", 66000), ("Kenji", "Tanaka", "Engineering", "DevOps Engineer", 98000), ("Fatima", "Al-Hassan", "HR", "Recruiter", 62000), ("Marco", "Rossi", "Sales", "Sales Manager", 110000), ("Ingrid", "Larsson", "Finance", "Controller", 105000), ("Yaw", "Asante", "Engineering", "Data Engineer", 93000), ("Mei", "Chen", "Marketing", "Growth Lead", 88000), ("Arjun", "Sharma", "Operations", "Supply Chain Analyst", 74000), ("Elena", "Petrov", "Engineering", "QA Engineer", 78000), ("Noah", "Williams", "Sales", "Business Dev Rep", 65000), ("Hana", "Kimura", "HR", "Payroll Specialist", 60000), ("David", "Osei", "Finance", "Budget Analyst", 77000), ("Clara", "Santos", "Engineering", "Tech Lead", 115000), ("Ravi", "Patel", "Marketing", "SEO Specialist", 69000), ("Awa", "Traoré", "Operations", "Logistics Coordinator", 61000), ("Erik", "Andersen", "Sales", "Enterprise AE", 120000), ("Mia", "Johansson", "Engineering", "Mobile Developer", 89000), ("Oluwaseun", "Adeyemi", "Finance", "Accounts Payable", 59000), ("Lena", "Bauer", "HR", "Training Coordinator", 63000), ("Diego", "Herrera", "Engineering", "Platform Engineer", 97000), ("Aisha", "Mohammed", "Marketing", "Social Media Manager", 67000), ("Takeshi", "Yamamoto", "Operations", "Procurement Specialist", 72000), ("Isabelle", "Leclerc", "Sales", "Inside Sales Rep", 64000), ("Felix", "Brandt", "Engineering", "Security Engineer", 102000), ("Nadia", "Kowalski", "Finance", "FP&A Analyst", 83000), ("Samuel", "Mensah", "HR", "Benefits Administrator", 58000), ("Victoria", "Kozlov", "Engineering", "Machine Learning Engineer", 125000), ("Kai", "Fischer", "Marketing", "Brand Manager", 85000), ("Amira", "Ben-Ali", "Operations", "Project Coordinator", 71000), ("Lucas", "Martins", "Sales", "Regional Manager", 108000), ("Yuna", "Park", "Engineering", "Full Stack Developer", 91000), ("Omar", "Ibrahim", "Finance", "Tax Specialist", 86000), ("Hannah", "Schmidt", "HR", "HR Coordinator", 61000), ("Darius", "Popescu", "Engineering", "Embedded Systems", 94000), ("Chiara", "Ferrari", "Marketing", "Product Marketer", 81000), ("Kwame", "Boateng", "Operations", "Quality Analyst", 68000), ("Alice", "Nguyen", "Sales", "Solutions Consultant", 96000), ("Rafael", "Morales", "Finance", "Treasury Analyst", 79000), ("Leila", "Ahmadi", "Engineering", "Cloud Architect", 130000), ("Jordan", "Campbell", "HR", "Talent Acquisition", 70000), ("Sven", "Eriksson", "Operations", "Facilities Manager", 65000), ("Nneka", "Obi", "Sales", "VP of Sales", 145000),]
class Command(BaseCommand): help = "Seed the database with 50 realistic employee records."
def handle(self, *args, **options): if Employee.objects.exists(): self.stdout.write("Database already seeded -- skipping.") return
employees = [ Employee( first_name=first, last_name=last, department=dept, role=role, salary=salary, ) for first, last, dept, role, salary in SEED_DATA ]
Employee.objects.bulk_create(employees) self.stdout.write(self.style.SUCCESS(f"Seeded {len(employees)} employees."))Run it:
python manage.py seedThe command inserts 50 realistic employee records. It checks whether data already exists, so running it twice does not duplicate rows.
Step 4 — Write the serializer
Create employees/serializers.py:
from rest_framework import serializersfrom .models import Employee
class EmployeeSerializer(serializers.ModelSerializer): """ Serializer for the Employee model.
ModelSerializer automatically generates fields and validation from the model definition, so no manual field declarations are needed. The `id` field is read-only and becomes the rowId value on the frontend. """
class Meta: model = Employee fields = ["id", "first_name", "last_name", "department", "role", "salary"] read_only_fields = ["id"]What’s happening:
ModelSerializerinspects the model and generates field definitions and validation rules automatically.idis read-only because the database assigns it — the frontend never sends one for new rows.- The
fieldslist controls which columns appear in the API response and therefore which columns Handsontable receives.
Step 5 — Configure pagination
Create employees/pagination.py:
from rest_framework.pagination import PageNumberPaginationfrom rest_framework.response import Response
class EmployeePagination(PageNumberPagination): """ Custom pagination class that reads Handsontable's `pageSize` query parameter.
By default, DRF uses `page_size` as the query param name. Setting page_size_query_param = 'pageSize' lets DRF read the value that Handsontable's dataProvider sends automatically, so no URL translation is needed on the frontend.
The paginate_queryset / get_paginated_response pair is called by DRF automatically when a ViewSet uses this class. """
page_size = 10 page_size_query_param = "pageSize" # matches Handsontable's default param name max_page_size = 100
def get_paginated_response(self, data): """ Return the response shape Handsontable's dataProvider expects: { rows: [...], totalRows: N }
DRF's default shape is { count, next, previous, results }. We remap it here so the frontend fetchRows callback can return the object directly without any transformation. """ return Response( { "rows": data, "totalRows": self.page.paginator.count, } )
def get_paginated_response_schema(self, schema): return { "type": "object", "properties": { "totalRows": {"type": "integer"}, "rows": schema, }, }Why a custom pagination class?
DRF’s default response shape is { count, next, previous, results }. Handsontable’s dataProvider expects { rows, totalRows }. Overriding get_paginated_response converts the shape on the server side, so the fetchRows callback on the frontend can return res.json() without any extra transformation.
Why page_size_query_param = 'pageSize'?
Handsontable sends ?pageSize=10 automatically. DRF’s default param name is page_size. Setting page_size_query_param = 'pageSize' lets DRF read Handsontable’s value directly, so no URL translation is needed in fetchRows.
Step 6 — Write the ViewSet
Create employees/views.py:
import json
from django.db import transactionfrom django.db.models import Qfrom rest_framework import viewsetsfrom rest_framework.decorators import actionfrom rest_framework.filters import OrderingFilter, SearchFilterfrom rest_framework.response import Response
from .models import Employeefrom .pagination import EmployeePaginationfrom .serializers import EmployeeSerializer
# Whitelist for sorting -- also reused by the filter prop check.ALLOWED_ORDERING_FIELDS = {'first_name', 'last_name', 'department', 'role', 'salary'}
# Numeric fields must use exact (not iexact) to avoid casting errors on DecimalField.NUMERIC_FIELDS = {'salary'}
# Maps Handsontable Filters condition names to Django ORM lookup suffixes.# eq / not_eq are resolved dynamically below (numeric vs text distinction)._CONDITION_LOOKUP = { 'contains': ('icontains', False), 'not_contains': ('icontains', True), 'begins_with': ('istartswith', False), 'ends_with': ('iendswith', False), 'gte': ('gte', False), 'lte': ('lte', False), 'gt': ('gt', False), 'lt': ('lt', False),}
class EmployeeViewSet(viewsets.ModelViewSet): """ ViewSet providing paginated, sortable, and filterable employee data, plus batch CRUD actions that match Handsontable's dataProvider payload shape. """
queryset = Employee.objects.all() serializer_class = EmployeeSerializer pagination_class = EmployeePagination filter_backends = [OrderingFilter, SearchFilter] ordering_fields = list(ALLOWED_ORDERING_FIELDS) search_fields = ['first_name', 'last_name', 'department', 'role']
def get_queryset(self): queryset = Employee.objects.all()
# --- Sort --- # Handsontable sends sort[prop] + sort[order]; translate to a queryset # .order_by() call so DRF's OrderingFilter doesn't need to be patched. sort_prop = self.request.query_params.get('sort[prop]') sort_order = self.request.query_params.get('sort[order]', 'asc')
if sort_prop and sort_prop in ALLOWED_ORDERING_FIELDS: prefix = '' if sort_order == 'asc' else '-' queryset = queryset.order_by(f'{prefix}{sort_prop}')
# --- Filters --- # dataProvider serializes filters as a JSON array of DataProviderFilterColumn # objects: [{ prop, operation, conditions: [{ name, args }] }, ...] # # Each column can carry multiple conditions (e.g. "between" has two). # The operation field ('conjunction' | 'disjunction') tells us whether to # combine them with AND or OR. filters_json = self.request.query_params.get('filters') if filters_json: try: filter_cols = json.loads(filters_json) q = Q()
for col in filter_cols: prop = col.get('prop', '') operation = col.get('operation', 'conjunction') conditions = col.get('conditions') or []
if prop not in ALLOWED_ORDERING_FIELDS: continue
col_q_parts = [] is_numeric = prop in NUMERIC_FIELDS
for cond in conditions: name = cond.get('name') args = cond.get('args') or [] value = args[0] if args else None
if name == 'empty': # DecimalField rejects __exact='' -- use isnull for numeric. if is_numeric: col_q_parts.append(Q(**{f'{prop}__isnull': True})) else: col_q_parts.append(Q(**{f'{prop}__exact': ''}) | Q(**{f'{prop}__isnull': True})) continue
if name == 'not_empty': if is_numeric: col_q_parts.append(Q(**{f'{prop}__isnull': False})) else: col_q_parts.append(~Q(**{f'{prop}__exact': ''}) & ~Q(**{f'{prop}__isnull': True})) continue
# eq / neq: use exact for numeric, iexact for text. if name in ('eq', 'neq'): lookup = f'{prop}__exact' if is_numeric else f'{prop}__iexact' cond_q = Q(**{lookup: value}) col_q_parts.append(~cond_q if name == 'neq' else cond_q) continue
if name not in _CONDITION_LOOKUP or value is None: continue
lookup_suffix, negate = _CONDITION_LOOKUP[name] lookup = f'{prop}__{lookup_suffix}' cond_q = Q(**{lookup: value}) col_q_parts.append(~cond_q if negate else cond_q)
if not col_q_parts: continue
# Combine conditions within this column with AND or OR. col_q = col_q_parts[0] for part in col_q_parts[1:]: if operation == 'disjunction': col_q |= part else: col_q &= part
q &= col_q
queryset = queryset.filter(q) except (json.JSONDecodeError, TypeError, KeyError): pass
return queryset
# ------------------------------------------------------------------ # Batch CRUD actions # ------------------------------------------------------------------ # Standard REST endpoints (POST /employees/, PATCH /employees/{id}/, etc.) # operate on a single resource at a time. Handsontable's dataProvider # sends all mutations in a single request as arrays, so we need # custom @action endpoints that accept arrays.
@action(detail=False, methods=['post'], url_path='create-rows') @transaction.atomic def create_rows(self, request): """ POST /api/employees/create-rows/
Handsontable's onRowsCreate callback receives { rowsAmount } -- the number of rows the user wants to add. The backend creates that many empty rows via bulk_create and returns them with server-assigned ids so Handsontable can update its internal row map.
Payload shape: { rowsAmount: 2 } """ rows_amount = max(1, int(request.data.get('rowsAmount') or 1)) employees = Employee.objects.bulk_create([ Employee(first_name='', last_name='', department='', role='', salary=0) for _ in range(rows_amount) ]) serializer = EmployeeSerializer(employees, many=True) return Response(serializer.data, status=201)
@action(detail=False, methods=['patch'], url_path='update-rows') @transaction.atomic def update_rows(self, request): """ PATCH /api/employees/update-rows/
Handsontable's onRowsUpdate callback sends an array of objects with the row id and a changes dict containing only the modified fields.
Payload shape: [ { id: 7, changes: { salary: 102000 } }, { id: 12, changes: { department: "Marketing", role: "Team Lead" } } ] """ updated = []
for row in request.data: employee = Employee.objects.get(pk=row['id']) serializer = EmployeeSerializer(employee, data=row['changes'], partial=True) serializer.is_valid(raise_exception=True) serializer.save() updated.append(serializer.data)
return Response(updated)
@action(detail=False, methods=['delete'], url_path='remove-rows') def remove_rows(self, request): """ DELETE /api/employees/remove-rows/
Payload shape: [3, 7, 14] """ deleted_count, _ = Employee.objects.filter(pk__in=request.data).delete() return Response({'deleted': deleted_count})The key parts are the sort translation and the three batch CRUD actions.
Sort translation
Handsontable sends ?sort[prop]=salary&sort[order]=desc. See get_queryset in views.py for the translation.
What’s happening:
- Read Handsontable’s
sort[prop]andsort[order]params. - Prepend
-for descending order (Django ORM convention). - Call
queryset.order_by()directly.ALLOWED_ORDERING_FIELDSis a whitelist that prevents ORM injection through arbitrary field names.
Filter translation
dataProvider passes filters as a single filters query param containing a JSON-encoded array. Each element is a DataProviderFilterColumn object:
[ { "prop": "department", "operation": "conjunction", "conditions": [{ "name": "eq", "args": ["Engineering"] }] }]Decode and build a Django Q object. See get_queryset in views.py for the complete filter translation.
What’s happening:
json.loads(filters_json)decodes the singlefiltersparam into a list of column filter objects.- Each column object contains
prop,operation(conjunction= AND,disjunction= OR), and aconditionslist. - Conditions within one column combine according to
operation; columns always combine with AND. eq/nequseexactforsalary(DecimalField rejectsiexact) andiexactfor text fields.empty/not_emptyhandle both null and blank-string cases for text fields.ALLOWED_ORDERING_FIELDSwhitelists thepropvalue to prevent ORM injection.
Batch CRUD endpoints
Standard REST conventions use single-resource endpoints (POST /employees/, DELETE /employees/{id}/). Handsontable’s dataProvider sends all mutations as arrays in a single request. A DRF @action solves this without creating a separate URL pattern by hand. See create_rows, update_rows, and remove_rows in views.py.
What’s happening:
detail=Falseregisters the action at the list URL (/api/employees/) instead of the detail URL (/api/employees/{id}/).create_rowsreadsrowsAmountfrom the request and usesbulk_createto insert that many empty rows in one SQL statement. Returning them with their newidvalues letsdataProviderupdate its internal row map.partial=Trueinupdate_rowsallows updating a subset of fields (row['changes']) without requiring all fields to be present.filter(pk__in=ids).delete()removes multiple rows in a single SQL statement.
Why not use standard DELETE /api/employees/{id}/ for each row?
Deleting N rows individually requires N requests. A single batch request is faster and reduces network round trips.
Step 7 — Register URLs
Create employees/urls.py:
from django.urls import include, pathfrom rest_framework.routers import DefaultRouterfrom .views import EmployeeViewSet
# DefaultRouter generates URL patterns for all ViewSet actions automatically:# GET /api/employees/ -> list()# POST /api/employees/ -> create() (single-row; use create-rows/ for batch)# GET /api/employees/{id}/ -> retrieve()# PUT /api/employees/{id}/ -> update()# PATCH /api/employees/{id}/ -> partial_update()# DELETE /api/employees/{id}/ -> destroy()## The custom @action methods are registered automatically too:# POST /api/employees/create-rows/# PATCH /api/employees/update-rows/# DELETE /api/employees/remove-rows/
router = DefaultRouter()router.register(r"employees", EmployeeViewSet, basename="employee")
urlpatterns = [ path("api/", include(router.urls)),]Include this in your project’s root urls.py:
from django.urls import include, path
urlpatterns = [ path('', include('employees.urls')),]DefaultRouter generates all standard and custom action URLs automatically. You can verify the registered routes by visiting http://localhost:8000/api/ in a browser.
Step 8 — Configure CORS
The browser blocks cross-origin requests by default. Add django-cors-headers to allow requests from the frontend development server:
# settings.py -- relevant additions only.# Add these to your existing Django settings file.
INSTALLED_APPS = [ # ... your existing apps ... "rest_framework", "corsheaders", # django-cors-headers "django_filters", # django-filter (optional, for DjangoFilterBackend) "employees", # your app]
MIDDLEWARE = [ # CorsMiddleware must come before CommonMiddleware. "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", # ... rest of your middleware ...]
# --- CORS ---# Allow requests from the frontend dev server.# In production, restrict this to your actual domain(s).CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", # Vite dev server "http://localhost:3000", # Create React App / Next.js dev server "https://your-production-domain.com",]
# If your frontend sends credentials (cookies, Authorization headers), also set:# CORS_ALLOW_CREDENTIALS = True
# --- Django REST Framework ---REST_FRAMEWORK = { "DEFAULT_PAGINATION_CLASS": "employees.pagination.EmployeePagination", "PAGE_SIZE": 10, "DEFAULT_FILTER_BACKENDS": [ "rest_framework.filters.OrderingFilter", "rest_framework.filters.SearchFilter", # Uncomment to add DjangoFilterBackend for exact field matching: # "django_filters.rest_framework.DjangoFilterBackend", ],}Why must CorsMiddleware come before CommonMiddleware?
CorsMiddleware needs to intercept the preflight OPTIONS request before Django’s routing logic handles it. Placing it after CommonMiddleware can result in missing CORS headers on preflight responses.
Production note: Replace the dev server origins with your actual production domain. Never set CORS_ALLOW_ALL_ORIGINS = True in production.
Step 9 — Handle CSRF in the frontend
Django protects mutating endpoints with a CSRF token. It sets a csrftoken cookie on every response. Read it and include it in the X-CSRFToken header for every POST, PATCH, or DELETE request. See getCsrfToken in the code files in Step 11.
Why a cookie instead of a hidden form field?
Handsontable uses fetch(), not HTML form submission. Reading the token from the cookie (SameSite + CSRF double-submit pattern) works with any JavaScript HTTP client without server-side template changes.
Step 10 — Build the URL for fetchRows
Handsontable’s dataProvider calls fetchRows with a { page, pageSize, sort, filters } object. See buildUrl in the code files in Step 11.
What’s happening:
pageandpageSizeare sent as-is. DRF readspageSizedirectly becausepage_size_query_param = 'pageSize'was set in Step 5.sortis split intosort[prop]andsort[order]. The Django view reads both params and callsqueryset.order_by()directly (see Step 6).filtersis serialised withJSON.stringify.dataProviderpasses the fullDataProviderFilterColumnarray — includingoperationand nestedconditions— which Django decodes withjson.loads().API_BASEuses a relative path (/api/employees/). In development, Vite proxies/api/*tohttp://localhost:8000, so the browser and Django share one origin, which keeps CSRF cookies accessible without extra CORS configuration.
Step 11 — Initialize Handsontable
With the backend and Vite dev server running (bash setup.sh), open http://localhost:5173 to see the grid. The Django API runs on http://localhost:8000 inside Docker; Vite proxies all /api/* requests to it. The complete frontend code is in the files below.
import Handsontable from 'handsontable/base';import { registerPlugin, DataProvider, ContextMenu, Dialog, DropdownMenu, Filters, ColumnSorting, Pagination, EmptyDataState, Notification,} from 'handsontable/plugins';import { registerAllCellTypes } from 'handsontable/cellTypes';
registerPlugin(DataProvider);registerPlugin(ContextMenu);registerPlugin(Dialog);registerPlugin(DropdownMenu);registerPlugin(Filters);registerPlugin(ColumnSorting);registerPlugin(Pagination);registerPlugin(EmptyDataState);registerPlugin(Notification);registerAllCellTypes();
// ---------------------------------------------------------------------------// Step 1: Read Django's CSRF token from the cookie.//// Django sets a `csrftoken` cookie on every response. You must read it and// include it in the X-CSRFToken request header for every mutating request// (POST, PATCH, DELETE). Without it Django returns 403 Forbidden.// ---------------------------------------------------------------------------function getCsrfToken() { return document.cookie .split('; ') .find((row) => row.startsWith('csrftoken=')) ?.split('=')[1];}
// ---------------------------------------------------------------------------// Step 2: Build the request URL for fetchRows.//// Handsontable's dataProvider calls fetchRows with { page, pageSize, sort,// filters }. This helper converts those into query string parameters that// Django REST Framework understands.//// - `page` and `pageSize` map directly.// - `sort` becomes sort[prop] + sort[order].// - `filters` is a DataProviderFilterColumn[] array -- pass it as a JSON// string so Django can parse the full nested structure (prop, operation,// conditions: [{ name, args }]) with a single json.loads() call.//// Vite proxies /api/* → http://localhost:8000, so we use a relative URL.// ---------------------------------------------------------------------------const API_BASE = '/api/employees/';
function buildUrl({ page, pageSize, sort, filters }) { const params = new URLSearchParams();
params.set('page', page); params.set('pageSize', pageSize);
if (sort?.prop) { params.set('sort[prop]', sort.prop); params.set('sort[order]', sort.order ?? 'asc'); }
// Pass the full filter payload as a JSON string so Django can parse the // nested conditions structure with json.loads(). if (filters?.length) { params.set('filters', JSON.stringify(filters)); }
return `${API_BASE}?${params.toString()}`;}
// ---------------------------------------------------------------------------// Step 3: Initialize Handsontable with the dataProvider plugin.// ---------------------------------------------------------------------------const container = document.querySelector('#example1');
let removeConfirmed = false;
const hot = new Handsontable(container, { dataProvider: { rowId: 'id',
fetchRows: async ({ page, pageSize, sort, filters }, { signal }) => { const url = buildUrl({ page, pageSize, sort, filters }); const res = await fetch(url, { signal });
if (!res.ok) { throw new Error(`Fetch failed: ${res.status}`); }
// pagination.py already maps DRF's { count, results } to // { rows, totalRows }, so we can return the JSON directly. return res.json(); },
onRowsCreate: async ({ rowsAmount }) => { const res = await fetch(`${API_BASE}create-rows/`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(), }, body: JSON.stringify({ rowsAmount }), });
if (!res.ok) { throw new Error(`Create failed: ${res.status}`); }
const data = await res.json(); const info = data.map(r => `(id: ${r.id})`).join(', '); hot.getPlugin('notification').showMessage({ variant: 'success', title: 'Row added', message: `Created: ${info}`, duration: 3000, }); return data; },
onRowsUpdate: async (rows) => { const res = await fetch(`${API_BASE}update-rows/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(), }, body: JSON.stringify(rows), });
if (!res.ok) { throw new Error(`Update failed: ${res.status}`); } },
onRowsRemove: async (rowIds) => { const res = await fetch(`${API_BASE}remove-rows/`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(), }, body: JSON.stringify(rowIds), });
if (!res.ok) { throw new Error(`Delete failed: ${res.status}`); } }, },
beforeRowsMutation(operation, payload) { if (operation === 'remove' && !removeConfirmed) { const count = payload.rowsRemove.length; const notification = hot.getPlugin('notification'); const id = notification.showMessage({ variant: 'warning', title: 'Delete rows', message: `Delete ${count} row${count !== 1 ? 's' : ''}? This cannot be undone.`, duration: 0, actions: [ { label: 'Delete', type: 'primary', callback: () => { notification.hide(id); removeConfirmed = true; hot.getPlugin('dataProvider').removeRows(payload.rowsRemove).finally(() => { removeConfirmed = false; }); }, }, { label: 'Cancel', type: 'secondary', callback: () => notification.hide(id), }, ], }); return false; } },
pagination: { pageSize: 10 }, columnSorting: true, filters: true, dropdownMenu: ['filter_by_condition', 'filter_action_bar'], contextMenu: true, emptyDataState: true, notification: true, dialog: true,
colHeaders: ['First Name', 'Last Name', 'Department', 'Role', 'Salary'], columns: [ { data: 'first_name', type: 'text' }, { data: 'last_name', type: 'text' }, { data: 'department', type: 'text' }, { data: 'role', type: 'text' }, { data: 'salary', type: 'numeric', numericFormat: { pattern: '$0,0' } }, ],
rowHeaders: true, height: 400, width: '100%', autoWrapRow: true, licenseKey: 'non-commercial-and-evaluation',});
// eslint-disable-next-line no-unused-varsexport { hot };Key options explained:
| Option | What it does |
|---|---|
rowId: 'id' | Tells dataProvider which field identifies a row. Must match the serializer field name. |
{ signal } in fetchRows | Pass the AbortSignal to fetch() so in-flight requests are cancelled when the user sorts or filters before the previous response arrives. |
return res.json() in onRowsCreate | Return the server response so dataProvider can update its internal row map with the server-assigned id values. |
pagination: { pageSize: 10 } | Enables the pagination toolbar. dataProvider sends the current page and size to fetchRows automatically. |
columnSorting: true | Enables column header click-to-sort. The sort state is passed to fetchRows on each change. |
filters: true | Enables the column filter UI. Active conditions are passed to fetchRows on each change. |
emptyDataState: true | Shows a friendly illustration when fetchRows returns zero rows (for example, when a filter matches nothing). |
notification: true | Shows automatic error toast notifications when fetchRows or a mutation callback throws. Fetch failures include a Refetch action. |
beforeRowsMutation | Intercepts delete operations before they are sent. Used here to show a confirmation dialog; the actual delete is re-issued after the user confirms. |
contextMenu: true | Enables the right-click context menu with “Insert row above / below” and “Remove row” items. |
How it works — Complete flow
- Initial load:
dataProvidercallsfetchRows({ page: 1, pageSize: 10 }). The view returns the first 10 rows and the total row count. - User clicks a column header:
columnSortingupdates its sort state anddataProvidercallsfetchRowsagain withsort: { prop: 'salary', order: 'desc' }. The Django view translates this to?ordering=-salaryforOrderingFilter. - User applies a column filter: The filter UI updates its condition list and
dataProvidercallsfetchRowswith thefiltersarray. The Django view decodes thefiltersJSON string withjson.loads()and builds a DjangoQobject from each column’sconditionslist. - User navigates to page 2:
dataProvidercallsfetchRows({ page: 2, pageSize: 10, ... }). - User edits a cell:
dataProvidercollects all changed cells for that row and callsonRowsUpdatewith[{ id: 7, salary: 102000 }]. Theupdate-rowsendpoint applies a partial update. - User adds a row:
dataProvidercallsonRowsCreatewith the new row values. Thecreate-rowsendpoint inserts the row and returns it with anid.dataProviderupdates its internal map so future edits use the correct id. - User deletes rows:
dataProvidercallsonRowsRemovewith the selected row ids. Theremove-rowsendpoint deletes all matching rows in a single SQL statement.
What you learned
- DRF’s default response shape (
{ count, results }) differs from whatdataProviderexpects ({ rows, totalRows }). Overrideget_paginated_responsein a custom pagination class to map the shape on the server. - Set
page_size_query_param = 'pageSize'so DRF reads Handsontable’s parameter name directly. - Read Handsontable’s
sort[prop]andsort[order]params inget_querysetand callqueryset.order_by()directly. Whitelist the field name withALLOWED_ORDERING_FIELDSto prevent ORM injection. - Receive Handsontable’s
filtersparam as a JSON string and decode it withjson.loads(). Each entry is{ prop, operation, conditions: [{ name, args }] }— conditions within a column combine with AND or OR; columns always combine with AND. - Use DRF
@actionendpoints for batch CRUD instead of single-resource REST routes. - Read Django’s CSRF token from the
csrftokencookie and include it as theX-CSRFTokenheader in all mutating requests. - Place
CorsMiddlewarebeforeCommonMiddlewareso preflight requests receive CORS headers.
Next steps
- Server-side data documentation — full
dataProviderAPI reference - Rows pagination guide
- Column filter guide
- Rows sorting guide