Files
mizan/README.md
2026-03-31 17:15:06 +00:00

9.9 KiB

Djarea

Django + React server functions. RPC, not REST.

Write a Python function. Djarea generates a typed React hook. No routes, no serializers, no endpoint boilerplate.

@client
def current_user(request) -> UserShape:
    return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0]
const user = useCurrentUser()  // typed, cached, SSR-hydrated

The decorator is the API contract. The Shape is the query plan. The hook is generated. That's it.

What Djarea does

A @client function in Django becomes a callable hook in React. The function's type signature controls everything — input validation, output serialization, TypeScript types, and SQL projection.

class ArticleShape(Shape[Article]):
    id: int | None = None
    title: str
    author: FlatAuthorShape
    tags: list[TagShape] = []

This Shape does three things at once:

  • Defines the Pydantic model for validation and serialization
  • Generates the django-readers spec, so the SQL query selects exactly these fields and nothing else
  • Produces the TypeScript type on the React side

One definition. Three layers stay in sync automatically.

Quick start

1. Django setup

# settings.py
INSTALLED_APPS = [
    "djarea",
    "myapp",
]

# urls.py
from django.urls import include, path
urlpatterns = [
    path("api/djarea/", include("djarea.urls")),
]

# asgi.py (for WebSocket support)
from djarea import wrap_asgi
from django.core.asgi import get_asgi_application
application = wrap_asgi(get_asgi_application())

2. Define server functions

# myapp/djarea_clients.py
from djarea.client import client
from djarea.shapes import Shape
from pydantic import BaseModel

class EchoOutput(BaseModel):
    message: str

@client
def echo(request, text: str) -> EchoOutput:
    return EchoOutput(message=text)

Functions in djarea_clients.py are discovered automatically — same convention as models.py.

3. Generate TypeScript

// django.config.mjs
export default {
    source: {
        django: {
            managePath: '../backend/manage.py',
            command: ['uv', 'run', 'python'],
        },
    },
    output: 'src/api/generated.ts',
}
npx djarea-generate

4. Use in React

import { DjangoContext, useEcho, useCurrentUser, DjangoError } from '@/api'

// layout.tsx — one provider, handles everything
export default function Layout({ children }) {
    return <DjangoContext>{children}</DjangoContext>
}

// page.tsx
function MyComponent() {
    const user = useCurrentUser()
    const echo = useEcho()

    const handleClick = async () => {
        try {
            const result = await echo({ text: 'hello' })
            console.log(result.message) // typed
        } catch (e) {
            if (e instanceof DjangoError) {
                console.log(e.code)     // NOT_FOUND, VALIDATION_ERROR, etc.
                e.getFieldErrors('email') // field-level errors
            }
        }
    }
}

Shapes

Shapes are Djarea's projection system. A Shape defines exactly which fields to select from the database, validated through Pydantic and projected through django-readers. Different views get different Shapes — same model, different queries.

# Full detail page — joins books with chapters
class AuthorDetailShape(Shape[Author]):
    id: int | None = None
    name: str
    bio: str
    books: list[BookShape] = []

# Dropdown menu — two columns, no joins
class FlatAuthorShape(Shape[Author]):
    id: int | None = None
    name: str
# Detail page: SELECT id, name, bio + prefetch books
authors = AuthorDetailShape.query()

# Dropdown: SELECT id, name. That's it.
authors = FlatAuthorShape.query()

Shapes also support diffing. When the frontend sends state back, the diff system compares incoming data against the current database state and tells you exactly what changed:

@client
def update_articles(request, articles: list[ArticleShape]) -> dict:
    for article, diff in ArticleShape.diff_many(articles):
        if diff.is_new:
            create_article(article)
        elif diff.changed:
            update_fields(article, diff.changed)
        for tag in diff.tags.created:
            add_tag(article, tag)
        for tag_id in diff.tags.deleted:
            remove_tag(article, tag_id)
    return {"ok": True}

One query fetches all current state. The diff is per-field and per-nested-relation. Your service code only touches what actually changed.

The @client decorator

The decorator controls transport, caching, auth, and SSR behavior:

Decorator React hook What it does
@client useEcho() HTTP call, returns typed result
@client(context='global') useCurrentUser() Fetched once, cached in context, SSR-hydrated
@client(context='local') useArticle({ id }) Cached per unique params
@client(websocket=True) useSearch() Runs over WebSocket instead of HTTP
@client(auth=True) Requires authentication
@client(auth='staff') Requires staff status
@client(auth=my_check) Custom auth callable

Forms

Django forms become typed React hooks with client-side Zod validation:

class ContactForm(DjareaFormMixin, forms.Form):
    djarea = DjareaFormMeta(
        name="contact",
        title="Contact Us",
        submit_label="Send",
        live_validation=True,
    )
    name = forms.CharField(max_length=100)
    email = forms.EmailField()
    message = forms.CharField(widget=forms.Textarea)

    def on_submit_success(self, request):
        send_email(self.cleaned_data)
        return {"sent": True}
const form = useContactForm()

form.schema          // field metadata, title, submit label
form.data            // { name: '', email: '', message: '' }
form.set('email', v) // typed setter
form.errors          // field-level errors (Zod + server)
form.submit()        // → { success: true, data: { sent: true } }

Zod schemas are generated from the Django form definition. Validation runs client-side first, server-side second. No duplicated validation logic.

Channels

WebSocket channels with typed messages:

class ChatChannel(ReactChannel):
    class Params(BaseModel):
        room: str
    class ReactMessage(BaseModel):
        text: str
    class DjangoMessage(BaseModel):
        text: str
        user: str

    def authorize(self, params):
        return self.user.is_authenticated

    def group(self, params):
        return f"chat_{params.room}"

    def receive(self, params, msg):
        return self.DjangoMessage(text=msg.text, user=self.user.email)
const chat = useChatChannel({ room: 'general' })

chat.status    // 'connecting' | 'connected' | 'disconnected'
chat.messages  // ChatDjangoMessage[]
chat.send({ text: 'hello' })

Architecture

React app
  └─ <DjangoContext>           ← generated provider (session, CSRF, WebSocket)
       ├─ useCurrentUser()     ← context hook (SSR-hydrated)
       ├─ useEcho()            ← function hook
       ├─ useContactForm()     ← form hook (Zod + server validation)
       └─ useChatChannel()     ← channel hook (WebSocket)
            │
            ├─ HTTP: POST /api/djarea/call/  { fn: "echo", args: { text: "hi" } }
            └─ WS:   { action: "rpc", fn: "echo", args: { text: "hi" } }
                     │
                     Django executor
                       ├─ Pydantic input validation
                       ├─ Auth check
                       ├─ Function execution
                       └─ Pydantic output serialization

All transport goes through a single endpoint. The generated DjangoContext is the only provider. It handles session init, CSRF, context auto-fetching, and WebSocket connection.

Code generation

npx djarea-generate reads Django schemas at build time (no running server) and produces:

File Contents
generated.djarea.ts Pydantic model types
generated.django.tsx DjangoContext provider + typed hooks
generated.django.server.ts SSR hydration helper
generated.forms.ts Form hooks with Zod schemas
generated.channels.ts Channel message types
generated.channels.hooks.tsx Channel hooks
index.ts Re-exports

Error handling

All errors from server functions throw as DjangoError:

if (e instanceof DjangoError) {
    e.code              // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | ...
    e.message           // human-readable
    e.details           // field-level validation errors
    e.isAuthError()
    e.isValidationError()
    e.getFieldErrors('email')
}

Why RPC instead of REST

REST exposes your database tables as CRUD endpoints and pushes business logic to the frontend. "Submit an application" becomes PATCH one resource, POST another, PUT a third — choreographed by client code.

Djarea keeps business logic on the server. You write functions that do things. The frontend calls them. The server knows what "submit" means. The client doesn't need to.

If you delete the frontend of a REST app, your backend is a database. If you delete the frontend of a Djarea app, your backend still has your entire application logic.

Packages

Package Install
djarea (Python) pip install djarea
@rythazhur/djarea (TypeScript) npm install @rythazhur/djarea

For WebSocket support: pip install "djarea[channels]"

Testing

# Django
cd django && uv run pytest

# React
cd react && npm test

# E2E (Playwright, real browser + real backend)
docker compose -f docker-compose.test.yml up -d
cd e2e/harness && npx djarea-generate && npx playwright test

# Everything
make test-all

Project structure

djarea/
  django/          Python package
  react/           TypeScript package
  example/         Integration test backend
  e2e/             Playwright E2E tests
  Makefile         Test orchestration