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
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 = ""
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()
{% 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>
<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>
.todo-strike {
text-decoration: line-through;
}
export default {
lastTitleValue: "",
inputDeleteDown() {
this.lastTitleValue = this.title;
},
inputDeleteUp() {
if (this.title === "" && this.lastTitleValue === "") {
this.delete_item()
}
}
}
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:
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 = []
{% 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.
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
{% 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.
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>
"""
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?
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
<div class="card mt-2 ">
<div class="card-body fs-5">
{% livevar headline %}
</div>
</div>
{% NewsTicker subscribe="notifications.news.headline" %}
while True:
news = await BreakingNews.objects.all()order_by("?").afirst()
await ComponentDispatcher.update_data(
"notifications.news.headline", {
"headline": news.title,
},
)
await asyncio.sleep(10)
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.