Alpha v0.8.5

Full stack reactive component framework for Django using Alpine.js

Tetra is a new full stack component framework for Django, bridging the gap between your server logic and front end presentation. It uses a public shared state and a resumable server state to enable inplace reactive updates. It also encapsulates your Python, HTML, JavaScript and CSS into one file or directory for proximity of related concerns.

See how easy it is to build a todo list

Python: Todo List
from demo.models import ToDo
from tetra import ReactiveComponent, public


class TodoList(ReactiveComponent):
    title = public("")
    subscription = "demo.todo"

    def load(self, *args, **kwargs):
        self.todos = ToDo.objects.filter(
            session_key=self.request.session.session_key,
        )

    @public
    def add_todo(self, title: str):
        if self.title:
            todo = ToDo(
                title=title,
                session_key=self.request.session.session_key,
            )
            todo.save()
            self.title = ""
Python: Todo Item
from tetra import public
from tetra.components.reactive import ReactiveComponent
from demo.models import ToDo


class TodoItem(ReactiveComponent):
    title = public("")
    done = public(False)
    model_version = public(0)
    todo: ToDo | None = None

    def load(self, todo: ToDo, *args, **kwargs):
        self.todo = todo
        self.title = todo.title
        self.done = todo.done
        self.model_version = todo.model_version

    def get_subscription(self) -> str:
        return self.todo.get_tetra_instance_channel()

    @public.watch("title", "done")
    @public.debounce(200)
    def save(self, value, old_value, attr):
        self.todo.title = self.title
        self.todo.done = self.done
        self.todo.save()
        # Update local version to match saved version
        self.model_version = self.todo.model_version

    @public(update=False)
    def delete_item(self):
        # Delete the todo - ReactiveModel will send WebSocket notification
        self.todo.delete()
        self.client._removeComponent()
HTML: Todo List
{% load i18n %}
<div {% ... attrs %}>
  <div class="input-group mb-2">
    <input type="text" x-model="title" class="form-control" placeholder="{% translate 'New task...' %}"
           @keyup.enter="add_todo(title)" autofocus>
    <button class="btn btn-primary" :class="{'disabled': title == ''}" @click="add_todo(title)">{% translate 'Add' %}</button>
  </div>
  <div class="list-group">
    {% for todo in todos %}
      {% TodoItem todo=todo key=todo.pk / %}
    {% endfor %}
  </div>
</div>
HTML: Todo Item
<div class="list-group-item d-flex gap-1 p-1" {% ... attrs %}>
  <label class="align-middle px-2 d-flex">
    <input class="form-check-input m-0 align-self-center" type="checkbox"
           x-model="done">
  </label>
  <input
      type="text"
      class="form-control border-0 p-0 m-0"
      :class="{'text-muted': done, 'todo-strike': done}"
      x-model="title"
      maxlength="80"
      @keydown.backspace="inputDeleteDown()"
      @keyup.backspace="inputDeleteUp()"
  >
  <button @click="delete_item()" class="btn btn-sm">
    <i class="fa-solid fa-trash"></i>
  </button>
</div>
CSS
.todo-strike {
    text-decoration: line-through;
}
Javascript
export default {
    lastTitleValue: "",
    inputDeleteDown() {
        this.lastTitleValue = this.title;
    },
    inputDeleteUp() {
        if (this.title === "" && this.lastTitleValue === "") {
            this.delete_item()
        }
    }
}
Python: Models
from django.db import models


class ToDo(models.Model):
    session_key = models.CharField(max_length=40, db_index=True)
    title = models.CharField(max_length=80)
    done = models.BooleanField(default=False)

Or a reactive server rendered search component:

Python
import itertools
from tetra import Component, public
from demo.movies import movies


class ReactiveSearch(Component):
    query = public("")
    results = []

    @public.watch("query")
    @public.throttle(200, leading=False, trailing=True)
    def watch_query(self, value, old_value, attr):
        if self.query:
            self.results = itertools.islice(
                (movie for movie in movies if self.query.lower() in movie.lower()),
                20,
            )
        else:
            self.results = []
HTML
{% load i18n %}
<div>
  <p>
    <input class="form-control" placeholder="{% translate 'Search for an 80s movie...' %}"
           type="text" x-model="query">
  </p>
  <ul>
    {% for result in results %}
      <li>{{ result }}</li>
    {% endfor %}
  </ul>
</div>

Or a counter, but with multiple nested instances.

Python
from tetra import Component, public


class Counter(Component):
    count = 0
    current_sum = 0

    def load(self, current_sum=None, *args, **kwargs):
        if current_sum is not None:
            self.current_sum = current_sum

    @public
    def increment(self):
        self.count += 1

    @public
    def decrement(self):
        self.count -= 1

    def sum(self):
        return self.count + self.current_sum
Django Template
{% load tetra %}
{% Counter key="counter-1" %}
  {% Counter key="counter-2" current_sum=sum %}
    {% Counter key="counter-3" current_sum=sum / %}
  {% /Counter %}
{% /Counter %}

Count: 0, Sum: 0

Count: 0, Sum: 0

Count: 0, Sum: 0

Or a simple card, with dynamic content.

Python
from sourcetypes import django_html
from django.utils.translation import gettext_lazy as _

from tetra import Component, public


class InfoCard(Component):
    title: str = _("I'm so excited!")
    content: str = _("We got news for you.")
    name: str = public("")

    @public
    def close(self):
        self.client._removeComponent()

    @public
    def done(self):
        print("User clicked on OK, username:", self.name)
        self.content = _("Hi {name}! No further news.").format(name=self.name)

    # language=html
    template: django_html = """
    {% load i18n %}
    <div class="card text-white bg-secondary mb-3" style="max-width: 18rem;">
      <div class="card-header d-flex justify-content-between">
        <h3>{% translate "Information" %}</h3>
        <button class="btn btn-sm btn-warning" @click="_removeComponent(
        )"><i class="fa fa-x"></i></button>
      </div>
      
      <div class="card-body">
        <h5 class="card-title">{{ title }}</h5>
        <p class="card-text">
          {{ content }}
        </p>
        <p x-show="!name">
          {% translate "Enter your name below!" %}
        </p>
        <p x-show="name">
            {% translate "Thanks," %} {% livevar name %}
        </p>
        <div class="input-group mb-3">

          <input 
            type="text" 
            class="form-control" 
            placeholder="{% translate 'Your name' %}"
            @keyup.enter="done()"
            x-model="name">
        </div>
        <button 
            class="btn btn-primary" 
            @click="done()" 
            :disabled="name == ''">
            {% translate "Ok" %}
        </button>
      </div>
      
    </div>
    """
Django Template

Information

I'm so excited!

We got news for you.

Enter your name below!

Thanks,

Not convinced? How about a reactive news ticker with push notifications?

Python
from demo.models import BreakingNews
from tetra import public, ReactiveComponent


class NewsTicker(ReactiveComponent):
    headline: str = public("")
    # could be a fixed subscription too:
    # subscription = "notifications.news.headline"

    def load(self, *args, **kwargs) -> None:
        # Fetch random news headline from database
        self.headline = BreakingNews.objects.all().order_by("?").first().title
HTML
<div class="card mt-2 ">
  <div class="card-body fs-5">
    {% livevar headline %}
  </div>
</div>

Django Template
{% NewsTicker subscribe="notifications.news.headline" %}
Async server code
while True:
    news = await BreakingNews.objects.all()order_by("?").afirst()

    await ComponentDispatcher.update_data(
      "notifications.news.headline", {
        "headline": news.title,
      },
    )
    await asyncio.sleep(10)
    
    
The following (partly fake) news line is not polled, but pushed from the server to all connected clients simultaneously each 10 seconds.
This might look trivial, you could do that with polling too. But Tetra is smarter than that.
Open multiple browsers/tabs with this very page, even on your phone, and watch the news change simultaneously.
An async django task worker is pushing the news to all connected clients.

What does Tetra do?

Django on the backend, Alpine.js in the browser

Tetra combines the power of Django with Alpine.js to make development easier and quicker.

Component encapsulation

Each component combines its Python, HTML, CSS and JavaScript in one place for close proximity of related code.

Async HTTP calls

Server calls are asynchronous, ensuring the frontend remains responsive. Multiple, fast clicks on the same button are not dropped between HTTP calls.

Resumable server state

The components' full server state is saved between public method calls. This state is encrypted for security.

Public server methods

Methods can be made public, allowing you to easily call them from JS on the front end, resuming the component's state.

Shared public state

Attributes can be decorated to indicate they should be available in the browser as Alpine.js data objects.

Server "watcher" methods

Public methods can be instructed to watch a public attribute, enabling reactive re-rendering on the server.

Inplace updating from the server

Server methods can update the rendered component in place. Powered by the Alpine.js morph plugin.

Component library packaging

Every component belongs to a 'library'; their JS & CSS is packed together for quicker browser downloads. They are built incrementally, only if the source has changed.

Components with overridable slots

Components can have multiple {% slot(s) %} which can be overridden when used.

JS/CSS builds using esbuild

Both for development (built into runserver) and production your JS & CSS is built with esbuild.

Source Maps

Source maps are generated during development so that you can track down errors to the original Python files.

Syntax highlighting with type annotations

Tetra uses type annotations to syntax highlight your JS, CSS & HTML in your Python files with a VS Code plugin

Form components

A simple replacement for Django's FormView, but due to Tetra's dynamic nature, e.g. a field can change its value or disappear depending on other fields' values.

Event subscriptions

Both frontend and backend components can subscribe to JavaScript events, enabling seamless event-driven programming across the full stack.

File upload/downloads

Whenever a form contains a FileField, Tetra makes sure the uploading process works smooth, even with page reloads.

Integration with Django Messages

Django's messaging framework is deeply integrated into Tetra: Whenever a new message occurs, it is transformed into a JavaScript object and sent as an event that can be subscribed to by any component.

Loading indicators

While an AJAX request is in flight, you can show a loading indicator, globally, per component, or per call.

Reactive components / realtime updates

Component data can be updated from the server using websockets/push notification, even by async Django background tasks, Celery, signals etc.

Routing and query strings

Router components can serve as top-level page entry points, defining how an application responds to navigation. They map URL paths to components and control which sub-components are rendered dynamically based on the current route, enabling structured navigation and modular page composition.

More features are planned, including:

• ModelComponent for bootstrapping standard CRUD interactions.   • Integration with Django Validators   • Alpine.js directives for component state (loading ✅, offline, etc.)   • Page title and metadata in header.   • Pagination and Infinity Scroll components.   • PostCSS/SASS/etc. support.   • CSS scoped to a component.   • Additional authentication tools.   • Integration with UI & component toolkits.