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 Component, public
class TodoList(Component):
title = public("")
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 Component, public
class TodoItem(Component):
title = public("")
done = public(False)
def load(self, todo, *args, **kwargs):
self.todo = todo
self.title = todo.title
self.done = todo.done
@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()
@public(update=False)
def delete_item(self):
self.todo.delete()
self.client._removeComponent()
{% load i18n %}
<div>
<div class="input-group mb-2">
<input type="text" x-model="title" class="form-control" placeholder="{% translate 'New task...' %}" @keyup.enter="add_todo(title)">
<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.id / %}
{% 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?
import random
from demo.models import BreakingNews
from tetra import public, ReactiveComponent
class NewsTicker(ReactiveComponent):
headline: str = public("")
# could be a fixed subscription too:
# subscribe = ["notifications.news.headline"]
def load(self, *args, **kwargs) -> None:
# Fetch the latest news headline from database
self.breaking_news = BreakingNews.objects.all()
# get random item from BreakingNews
self.headline = random.choice(self.breaking_news).title
<div class="card mt-2 ">
<div class="card-body fs-5">
{% livevar headline %}
</div>
</div>
{% NewsTicker subscribe="notifications.news.headline" %}
while True:
count = await BreakingNews.objects.acount()
offset = random.randint(0, count - 1)
news = await BreakingNews.objects.all()[offset : offset + 1].aget()
await ComponentDispatcher.update_data(
"notifications.news.headline",
{
"headline": news.title,
},
)
await asyncio.sleep(5)
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.
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.
More features are planned, including:
• ModelComponent for bootstrapping standard CRUD interactions.
• Python type annotations
• Integration with Django Validators
• Alpine.js directives for component state (loading ✅, offline etc.)
• Routing and query strings ✅ for "whole page" components.
• Page title and metadata in header.
• Pagination and Infinity Scroll components.
• PostCSS/SASS/etc. support.
• CSS scoped to a component.
• Redirect responses.
• Additional authentication tools.
• Integration with UI & component toolkits.
• Bundling of esbuild to be npm free.