Event-Driven UIs com HTMX e Django: Feedback em Tempo Real com Signals, Emails, Notificações e Webhooks (Sem WebSockets)

Published on: 2026-01-23
Post image
pt event-driven-ui event-driven-architecture htmx-django django-signals htmx-real-time django-htmx-tutorial django-notifications django-webhooks django-signals-example htmx-ajax htmx-polling real-time-feedback-ui backend-driven-ui django-async

Interfaces modernas costumam falhar quando ações importantes acontecem “por trás” do sistema, mas nada visível aparece na tela. Uma compra pode disparar e-mail, notificação interna e webhooks, enquanto a interface permanece estática, criando incerteza e repetição de cliques.

Uma abordagem eficiente para resolver esse descompasso é construir uma UI orientada a eventos, em que eventos do backend refletem rapidamente no frontend. A combinação de HTMX com Django Signals viabiliza feedback quase instantâneo usando HTML dinâmico e eventos do servidor, sem exigir WebSockets nem um ecossistema pesado de JavaScript.

O que é uma UI orientada a eventos e por que ela melhora a experiência

Uma UI orientada a eventos é uma interface em que mudanças relevantes do sistema são comunicadas e refletidas visualmente assim que acontecem. O “evento” é uma ocorrência interna, como a criação de um pedido, o envio de um e-mail ou a confirmação de um webhook. Em aplicações tradicionais, a tela só muda após um redirecionamento ou atualização manual. Ao sincronizar eventos internos com atualizações parciais da página, o estado do sistema fica mais claro e coerente.

Esse modelo reduz ações duplicadas porque a interface demonstra progresso e confirmações de forma contínua. Também diminui custos de suporte, pois dúvidas como “funcionou?” aparecem menos quando o sistema mostra etapas concluídas. Outro ganho é a rastreabilidade: quando cada etapa marca um estado, fica mais fácil diagnosticar falhas. Por fim, torna o fluxo mais previsível, pois o que ocorre no backend passa a ter representação explícita na UI.

Papel do HTMX: HTML como motor de interatividade

HTMX é uma biblioteca que permite disparar requisições HTTP a partir de atributos HTML e trocar partes do DOM com a resposta do servidor. Em vez de escrever muito JavaScript, a interatividade surge de atributos como hx-get, hx-post, hx-target e hx-swap. A resposta do servidor geralmente é um fragmento HTML pronto para substituir ou inserir em um trecho da página. Assim, o servidor permanece responsável pela renderização, mantendo a lógica de apresentação centralizada.

Esse estilo facilita UIs “incrementais”, em que apenas um container muda após um evento. Também simplifica validações e mensagens de erro, pois o backend pode retornar o mesmo template com erros do formulário. O HTMX inclui recursos úteis como indicadores de carregamento (classe htmx-indicator) e gatilhos periódicos (polling) por hx-trigger. Quando combinado com rastreamento de estado no banco, o polling pode refletir progresso sem complexidade.

Papel do Django Signals: efeitos colaterais desacoplados

Django Signals são um mecanismo de publicação/assinatura dentro do Django que permite reagir a eventos do framework e do domínio. Um exemplo clássico é o post_save, disparado após salvar um model. O objetivo é executar efeitos colaterais de forma desacoplada, como enviar e-mail, criar notificações ou acionar webhooks. Em vez de entupir a view com muitas responsabilidades, os handlers de signal encapsulam reações a eventos do sistema.

Além dos signals “prontos” do Django, é possível criar signals customizados para representar etapas de negócio, como “e-mail enviado” ou “webhook confirmado”. Esses sinais ajudam a separar o “disparar ação” do “marcar progresso” e do “informar UI”. Quando o domínio cresce, o desacoplamento reduz impacto de mudanças e facilita testes unitários. Ainda assim, exige cuidado para não esconder regras críticas em handlers difíceis de rastrear.

Arquitetura proposta: eventos no backend e feedback incremental no frontend

A arquitetura combina três peças: uma ação principal (ex.: criar pedido), etapas assíncronas ou secundárias (e-mail, notificação, webhook) e uma UI que exibe progresso consultando o estado. Em termos de fluxo, a view cria o pedido e retorna uma tela de “pedido criado” com um componente que consulta periodicamente o progresso. Esse componente chama uma rota que renderiza um fragmento HTML com checkmarks e spinners. Quando tudo termina, a UI para de consultar e mostra o estado final.

O ponto central é que a UI não precisa saber “como” o e-mail foi enviado, apenas se foi enviado. Isso é resolvido com campos de rastreamento no model, como email_sent_at e webhook_triggered_at. Os signals atualizam esses campos ao concluir cada etapa. Com isso, o frontend se limita a renderizar um estado consistente e o backend mantém o histórico.

Modelagem do exemplo: pedidos e notificações com rastreamento de etapas

Um exemplo didático envolve um model Order (pedido) e um model Notification (notificação interna). O pedido guarda produto, quantidade e timestamps. Para mostrar progresso, o pedido também registra quando o e-mail foi enviado e quando o webhook foi confirmado. Já a notificação fica associada ao pedido e ao usuário, com flag de leitura.

O trecho abaixo apresenta uma modelagem completa e funcional, incluindo related_name para facilitar consultas e campos de rastreamento. Também há um método simples de string para identificação. Essa estrutura permite que a view de progresso responda com base no banco, sem variáveis globais nem estado em memória. Em cenários com múltiplos servidores, esse detalhe é essencial.

from django.conf import settings
from django.db import models


class Order(models.Model):
    usuario = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="pedidos",
    )
    produto = models.CharField(max_length=200)
    quantidade = models.PositiveIntegerField()
    criado_em = models.DateTimeField(auto_now_add=True)

    # Rastreio de etapas orientadas a eventos
    email_enviado_em = models.DateTimeField(null=True, blank=True)
    webhook_confirmado_em = models.DateTimeField(null=True, blank=True)

    def __str__(self):
        return f"Pedido #{self.id} - {self.usuario}"


class Notification(models.Model):
    usuario = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="notificacoes",
    )
    pedido = models.ForeignKey(
        Order,
        on_delete=models.CASCADE,
        related_name="notificacoes_do_pedido",
    )
    mensagem = models.TextField()
    criado_em = models.DateTimeField(auto_now_add=True)
    lida = models.BooleanField(default=False)

    def __str__(self):
        return f"Notificação #{self.id} - Pedido #{self.pedido_id}"

Signals: criação do pedido e emissão de eventos de progresso

O uso de post_save permite detectar quando um pedido foi criado e disparar efeitos colaterais. Para manter o progresso observável, signals customizados podem representar marcos, como “e-mail enviado” e “webhook disparado”. Cada handler faz duas coisas: executa a ação (ou delega) e registra o resultado. O registro no banco serve como fonte de verdade para a UI.

O exemplo abaixo inclui signals customizados e handlers separados para marcar o progresso. Isso cria uma separação clara: um handler coordena as ações e outros handlers apenas atualizam o estado do pedido. Também existe um signal de erro para registrar falhas sem quebrar a experiência por completo. Em ambientes reais, falhas de webhook e e-mail são comuns e precisam de rastreio.

import requests
from django.core.mail import send_mail
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import Signal, receiver
from django.utils import timezone

from .models import Notification, Order


# Signals customizados para acompanhamento de progresso
email_enviado = Signal()
notificacao_criada = Signal()
webhook_disparado = Signal()
processamento_com_erro = Signal()


@receiver(post_save, sender=Order)
def ao_criar_pedido(sender, instance: Order, created: bool, **kwargs):
    """
    Coordena efeitos colaterais após criação do pedido.
    Em caso de falha, emite um signal de erro.
    """
    if not created:
        return

    try:
        # 1) E-mail de confirmação
        send_mail(
            subject=f"Confirmação do Pedido #{instance.id}",
            message="Pedido recebido e em processamento.",
            from_email="noreply@sistema.local",
            recipient_list=[instance.usuario.email],
            fail_silently=False,
        )
        email_enviado.send(sender=Order, pedido=instance)

        # 2) Notificação interna
        Notification.objects.create(
            usuario=instance.usuario,
            pedido=instance,
            mensagem=f"Pedido #{instance.id} confirmado e em processamento.",
        )
        notificacao_criada.send(sender=Order, pedido=instance)

        # 3) Webhook para serviços externos
        try:
            resposta = requests.post(
                "https://api.exemplo.local/webhooks/pedidos",
                json={"pedido_id": instance.id, "status": "criado"},
                timeout=5,
            )
            sucesso = 200 <= resposta.status_code < 300
        except Exception:
            sucesso = False

        webhook_disparado.send(sender=Order, pedido=instance, sucesso=sucesso)

    except Exception as exc:
        processamento_com_erro.send(
            sender=Order,
            pedido=instance,
            erro=str(exc),
        )


@receiver(email_enviado)
def marcar_email_enviado(sender, pedido: Order, **kwargs):
    # Atualização direta e simples para refletir progresso no banco
    Order.objects.filter(id=pedido.id).update(email_enviado_em=timezone.now())


@receiver(webhook_disparado)
def marcar_webhook(sender, pedido: Order, sucesso: bool, **kwargs):
    if sucesso:
        Order.objects.filter(id=pedido.id).update(webhook_confirmado_em=timezone.now())


@receiver(processamento_com_erro)
def lidar_com_erro(sender, pedido: Order, erro: str, **kwargs):
    # Exemplo: registrar uma notificação de erro visível na UI
    Notification.objects.create(
        usuario=pedido.usuario,
        pedido=pedido,
        mensagem=f"Falha no processamento do pedido #{pedido.id}: {erro}",
    )

Views: criação do pedido e endpoint de progresso para o HTMX

Para uma UI incremental, a view de criação retorna HTML parcial no mesmo container, em vez de redirecionar. Quando o formulário é válido, o pedido é salvo e os signals são disparados automaticamente. Em seguida, o servidor devolve um template “pedido criado” contendo um bloco que consulta progresso. Para isso, uma segunda view retorna um fragmento HTML com o estado atual das etapas.

A view de progresso verifica campos do pedido e a existência de notificações ligadas ao pedido. Isso fornece um retrato fiel do processamento, mesmo que o servidor reinicie. Caso as etapas ainda estejam em andamento, o HTMX continua consultando. Quando estiver completo, o fragmento renderiza um estado final e o polling é interrompido pelo próprio template.

from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, render
from django.views.decorators.http import require_http_methods

from .forms import OrderForm
from .models import Order


@login_required
@require_http_methods(["GET", "POST"])
def criar_pedido(request):
    if request.method == "GET":
        form = OrderForm()
        return render(request, "orders/criar_pedido.html", {"form": form})

    form = OrderForm(request.POST)
    if not form.is_valid():
        return render(
            request,
            "orders/_form_pedido.html",
            {"form": form},
            status=400,
        )

    pedido = form.save(commit=False)
    pedido.usuario = request.user
    pedido.save()

    return render(
        request,
        "orders/pedido_criado.html",
        {"pedido": pedido},
        status=200,
    )


@login_required
@require_http_methods(["GET"])
def progresso_pedido(request, pedido_id: int):
    pedido = get_object_or_404(Order, id=pedido_id, usuario=request.user)

    email_ok = pedido.email_enviado_em is not None
    notificacao_ok = pedido.notificacoes_do_pedido.exists()
    webhook_ok = pedido.webhook_confirmado_em is not None

    completo = all([email_ok, notificacao_ok, webhook_ok])

    return render(
        request,
        "orders/_progresso.html",
        {
            "pedido": pedido,
            "email_ok": email_ok,
            "notificacao_ok": notificacao_ok,
            "webhook_ok": webhook_ok,
            "completo": completo,
        },
        status=200,
    )

Formulário e templates: submissão HTMX e troca de fragmentos

A camada de templates organiza a página em fragmentos reutilizáveis. O formulário é renderizado em um container principal e enviado com hx-post. O retorno do servidor substitui o conteúdo desse container usando hx-target e hx-swap. Assim, o mesmo espaço na tela troca do formulário para o “pedido criado” sem recarregar a página.

Para manter consistência, um fragmento separado renderiza o formulário com erros. Isso evita duplicação e facilita retornar status 400 com HTML pronto. O HTMX trata o retorno normalmente e atualiza a área-alvo. Esse padrão também mantém o backend no controle do HTML e das mensagens.

<div id="pedido-container">
  <div>
    <h4>Novo pedido</h4>
    <p>O envio do formulário inicia o processamento e a tela passa a exibir o andamento das etapas internas.</p>
  </div>

  <div
    hx-get="/pedidos/form/"
    hx-trigger="load"
    hx-target="#pedido-container"
    hx-swap="innerHTML">
  </div>
</div>
<!-- orders/_form_pedido.html -->
<form
  hx-post="/pedidos/criar/"
  hx-target="#pedido-container"
  hx-swap="innerHTML">

  {% csrf_token %}

  <div>
    <label>Produto</label>
    {{ form.produto }}
    {% if form.produto.errors %}<p>{{ form.produto.errors|striptags }}</p>{% endif %}
  </div>

  <div>
    <label>Quantidade</label>
    {{ form.quantidade }}
    {% if form.quantidade.errors %}<p>{{ form.quantidade.errors|striptags }}</p>{% endif %}
  </div>

  <button type="submit">Confirmar pedido</button>

  <div id="indicador" class="htmx-indicator">
    <p>Processando envio do pedido...</p>
  </div>
</form>
<!-- orders/pedido_criado.html -->
<div>
  <h4>Pedido criado</h4>
  <p>O pedido foi registrado e as ações secundárias estão sendo concluídas, com atualização automática do status.</p>

  <div
    id="progresso"
    hx-get="/pedidos/{{ pedido.id }}/progresso/"
    hx-trigger="load, every 1s"
    hx-swap="innerHTML">

    <p>Carregando andamento...</p>
  </div>
</div>
<!-- orders/_progresso.html -->
<div>
  <h4>Andamento do processamento</h4>
  <p>O status abaixo reflete o estado persistido no servidor, indicando quais etapas já foram concluídas.</p>

  <div>
    <p>{% if email_ok %}✓{% else %}⏳{% endif %} E-mail de confirmação</p>
    <p>{% if notificacao_ok %}✓{% else %}⏳{% endif %} Notificação interna</p>
    <p>{% if webhook_ok %}✓{% else %}⏳{% endif %} Webhook externo</p>
  </div>

  {% if completo %}
    <p><strong>Processamento concluído</strong>. O pedido segue o fluxo normal de atendimento.</p>
    <div hx-trigger="none"></div>
  {% endif %}
</div>

Atualização otimista e indicadores de carregamento

Uma atualização otimista ocorre quando a interface mostra uma resposta imediata antes mesmo do processamento terminar. No HTMX, isso pode ser feito com um indicador visual, reduzindo a sensação de “travamento”. O atributo hx-indicator associa um elemento a ser exibido durante requisições. Isso não confirma sucesso, mas comunica atividade do sistema.

O indicador também evita reenvio repetido por ansiedade de clique. Em formulários, pode ser combinado com desabilitar botão via CSS, mas o essencial é sinalizar que a requisição está em curso. Quando o HTML de resposta chega, o HTMX troca o container, removendo a necessidade de scripts. A UI continua leve e previsível.

<button
  type="submit"
  hx-indicator="#indicador">
  Confirmar pedido
</button>

<div id="indicador" class="htmx-indicator">
  <p>Enviando e iniciando processamento...</p>
</div>

Tratamento de erros: falhas em e-mail, notificação e webhook

Falhas em etapas secundárias acontecem por motivos comuns, como indisponibilidade de SMTP, tempo excedido em HTTP e instabilidade de rede. Em uma UI orientada a eventos, o objetivo é manter o pedido criado, mas marcar claramente que uma etapa falhou. Signals customizados podem registrar o erro criando uma notificação interna específica. Assim, o progresso não fica eternamente “girando” sem explicação.

Uma estratégia simples é persistir uma mensagem de erro relacionada ao pedido, permitindo que a view de progresso mostre esse estado. Outra estratégia é adicionar campos de erro no model, como webhook_erro ou email_erro, quando for necessário detalhar por etapa. A separação por etapa ajuda a diferenciar “não executou ainda” de “executou e falhou”. Essa clareza sustenta um fim bem definido para o fluxo.

Notificações em tempo quase real: badge de não lidas com polling moderado

Notificações internas podem aparecer como um contador (badge) que consulta periodicamente o servidor. Essa técnica usa polling e, apesar de não ser instantânea no sentido estrito, entrega um resultado prático com baixo custo de implementação. O endpoint retorna um fragmento HTML mínimo, como um número dentro de um span. O HTMX atualiza apenas o badge, sem recarregar a página.

Para evitar sobrecarga, o intervalo deve ser moderado, como 10 segundos, e pode variar conforme criticidade. Também é importante filtrar por usuário autenticado e considerar cache quando aplicável. O fragmento retornado deve ser pequeno para reduzir latência e banda. Esse padrão cobre bem cenários de “atualização contínua” sem complexidade.

from django.contrib.auth.decorators import login_required
from django.http import HttpResponse

from .models import Notification


@login_required
def contagem_notificacoes(request):
    qtd = Notification.objects.filter(usuario=request.user, lida=False).count()
    return HttpResponse(f"<span>{qtd}</span>")
<div
  id="badge-notificacoes"
  hx-get="/notificacoes/contagem/"
  hx-trigger="load, every 10s"
  hx-swap="innerHTML">
  <span>0</span>
</div>

Performance e escalabilidade: polling com atraso, interrupção e tarefas longas

O polling é simples, porém pode gerar carga se for agressivo. O HTMX permite usar modificadores como delay para reduzir picos e espaçar chamadas. Outro ponto crítico é interromper o polling quando o processamento termina. Ao renderizar um fragmento final sem hx-trigger recorrente, o componente deixa de fazer requisições.

Etapas longas, como envio de e-mail em volume ou integrações externas lentas, costumam ser delegadas a uma fila. Uma solução comum é usar um sistema de tarefas como Celery, que executa trabalho em background. Nesse modelo, o pedido é criado rapidamente e a UI acompanha o progresso enquanto tarefas concluem etapas e atualizam o banco. O resultado preserva o mesmo padrão: a UI consulta estado persistido, não estado transitório.

<div
  hx-get="/pedidos/{{ pedido.id }}/progresso/"
  hx-trigger="every 2s delay:1s"
  hx-swap="innerHTML">
</div>
from celery import shared_task
from django.core.mail import send_mail

from .models import Order
from .signals import email_enviado


@shared_task
def enviar_email_pedido(pedido_id: int):
    pedido = Order.objects.get(id=pedido_id)
    send_mail(
        subject=f"Confirmação do Pedido #{pedido.id}",
        message="Pedido recebido e em processamento.",
        from_email="noreply@sistema.local",
        recipient_list=[pedido.usuario.email],
        fail_silently=False,
    )
    email_enviado.send(sender=Order, pedido=pedido)

Testes: validação dos signals e do rastreamento de progresso

Testar uma arquitetura orientada a eventos exige verificar efeitos colaterais e marcações de estado. O foco é garantir que, ao criar um pedido, o envio de e-mail seja chamado e o timestamp seja gravado. Para não enviar e-mails reais, utiliza-se mock, que substitui a função por uma simulação. Também é importante validar que o handler de marcação foi acionado.

Ao manter a verdade no banco, os testes ficam mais determinísticos. Um teste pode criar um pedido e depois recarregar do banco para conferir campos atualizados. Caso tarefas assíncronas sejam usadas, testes podem rodar em modo “eager” no Celery, mas esse ajuste depende do ambiente de testes. O essencial é assegurar que cada etapa produza uma evidência persistida.

from django.contrib.auth import get_user_model
from django.test import TestCase
from unittest.mock import patch

from orders.models import Order


class TestSignalsPedido(TestCase):
    def setUp(self):
        self.usuario = get_user_model().objects.create_user(
            username="ana",
            email="ana@sistema.local",
            password="senha-forte-123",
        )

    @patch("orders.signals.send_mail")
    def test_criacao_pedido_dispara_email_e_marca_timestamp(self, mock_send_mail):
        pedido = Order.objects.create(
            usuario=self.usuario,
            produto="Produto de Teste",
            quantidade=1,
        )

        self.assertTrue(mock_send_mail.called)

        pedido.refresh_from_db()
        self.assertIsNotNone(pedido.email_enviado_em)

Fechamento: como o fluxo “era” e como passa a ser

No fluxo tradicional, o backend executa ações secundárias sem refletir nada na interface, gerando incerteza e repetição de submissões. A tela confirma apenas a criação do registro principal, mas não comunica etapas que também importam para confiança e previsibilidade. A depuração também se torna mais difícil, pois não existe um rastro claro por etapa no banco. O resultado é uma experiência fragmentada e pouco transparente.

No fluxo orientado a eventos com Django Signals e HTMX, o pedido é criado e a UI passa a renderizar progresso com base em estado persistido. Cada etapa conclui e grava um marcador, permitindo que a interface mostre avanços sem depender de JavaScript pesado. O polling é controlado e interrompido quando o processo termina, mantendo o sistema eficiente. O fim do fluxo fica explícito, com um estado final coerente e verificável, encerrando o ciclo de feedback com clareza.