Esta é a primeira parte de uma série de artigos sobre como escrever um framework baseado em TEA (The Elm Architecture) em TypeScript.

A Elm Architecture é utilizada em muitos lugares: Lustre em Gleam, Bubble Tea em Go e Iced em Rust.

Então vamos aprender criando um pequeno projeto usando a Elm Architecture, que poderemos migrar posteriormente para o nosso próprio framework TypeScript. Afinal, nunca existem frameworks JavaScript suficientes para a web.

Você não precisa de conhecimento prévio de Elm, mas deve ter um entendimento básico de TypeScript e eventos do DOM.

Podemos começar com um projeto Vite simples, usando o template Vanilla e TypeScript:

❯ pnpm create vite
◇  Project name:
│  tea
◇  Select a framework:
│  Vanilla
◇  Select a variant:
│  TypeScript
◇  Install with pnpm and start now?
│  Yes

Para este artigo, vamos usar lit-html para não termos que implementar duas funcionalidades principais nós mesmos: geração de HTML e renderização eficiente.

Podemos criar templates com interpolação JavaScript usando a tag html (por exemplo, ${...} dentro do template), e podemos vincular eventos do DOM com a sintaxe @event, semelhante ao Vue, e conceitualmente parecida com as props onEvent no React.

Dica

Se você quiser realce de sintaxe dentro de literais de template html, instale o plugin do VS Code para Lit.

pnpm add lit-html

Removemos tudo no main.ts e escrevemos uma view básica para o nosso app:

import { html, render } from "lit-html";

const view = html`
  <h1>Hello, World</h1>
  <p>Counter is at 0</p>
  <button>Click me</button>
`;

const root = document.getElementById("app")!;
render(view, root);

Renderizamos no #app usando lit-html. Em seguida, vamos tornar isso dinâmico usando interpolação:

import { html, render } from "lit-html";

let counter = 0;
const title = "Hello, World";

const view = html`
  <h1>${title}</h1>
  <p>Counter is at ${counter}</p>
  <button>Click me</button>
`;

const root = document.getElementById("app")!;
render(view, root);

Agora, vamos criar uma função que aumenta o contador e vinculá-la ao botão:

import { html, render } from "lit-html";

let counter = 0;
const title = "Hello, World";

const view = html`
  <h1>${title}</h1>
  <p>Counter is at ${counter}</p>
  <button @click=${increaseCounter}>Click me</button>
`;

const root = document.getElementById("app")!;
render(view, root);

function increaseCounter() {
  counter++;
}

Mas isso não vai funcionar. Por quê?

Quando o increaseCounter é executado, a view já foi criada e renderizada. Atualizamos o counter, mas não criamos uma nova view nem renderizamos novamente.

Vamos corrigir isso tornando a view uma função que recebe o estado e renderizando novamente após cada atualização:

import { html, render } from "lit-html";

let counter = 0;
const title = "Hello, World";

function view(counterAmount: number) {
  return html`
    <h1>${title}</h1>
    <p>Counter is at ${counterAmount}</p>
    <button @click=${increaseCounter}>Click me</button>
  `;
}

const root = document.getElementById("app")!;
render(view(counter), root);

function increaseCounter() {
  counter++;
  render(view(counter), root);
}

Agora, clicar no botão atualiza a UI novamente.

Em um nível macro, essa ideia aparece em muitos frameworks: a UI como uma função do estado. Os detalhes diferem (Virtual DOM, reatividade granular, otimizações em tempo de compilação), mas o loop principal é familiar.

Agora vamos aplicar as ideias centrais da Elm Architecture, mas encapsuladas em uma classe, semelhante a como a biblioteca iced funciona em Rust.

A Aplicação Counter

Em vez de funções e variáveis soltas, vamos encapsular a lógica da nossa aplicação em uma classe. Essa classe manterá o estado da nossa aplicação e definirá como atualizá-lo e visualizá-lo.

Primeiro, vamos definir nosso estado e mensagens:

type State = {
  value: number;
};

type Message = "Increment" | "Decrement";

Agora, vamos criar a classe Counter:

import { html, render } from "lit-html";

class Counter {
  // We use an internal state object
  state: State = {
    value: 0,
  };

  // The update method describes how state changes in response to messages
  update(message: Message) {
    // We capture a snapshot of the current state before the update logic starts.
    let state = this.state;

    switch (message) {
      case "Increment":
        this.state = { ...state, value: state.value + 1 };
        break;
      case "Decrement":
        this.state = { ...state, value: state.value - 1 };
        break;
    }
  }

  // The view method describes how the UI should look based on the current state
  view() {
    return html`
      <h1>Counter App</h1>
      <p>Count: ${this.state.value}</p>
      <button @click=${() => this.dispatch("Increment")}>+</button>
      <button @click=${() => this.dispatch("Decrement")}>-</button>
    `;
  }

  // The runtime logic
  private root = document.getElementById("app")!;

  dispatch(message: Message) {
    this.update(message);
    this.render();
  }

  render() {
    render(this.view(), this.root);
  }
}

const app = new Counter();
app.render();

Por que evitar mutação?

No método update, observe que não fizemos this.state.value++. Em vez disso, criamos um objeto totalmente novo: this.state = { ...state, value: state.value + 1 }.

Ao reatribuir o state em vez de mutar suas propriedades, preservamos o estado anterior na memória (desde que algo mantenha uma referência a ele). Esta é a base para funcionalidades como Time Traveling (Viagem no Tempo).

Time Traveling

Como nossa lógica de update é baseada na transição de um estado imutável para outro, podemos facilmente rastrear todos os estados em que nossa aplicação já esteve.

Imagine adicionar um array de histórico à nossa classe:

class Counter {
  state: State = { value: 0 };
  history: State[] = [];

  dispatch(message: Message) {
    // Save current state to history before updating
    this.history.push(this.state);

    this.update(message);
    this.render();
  }

  undo() {
    const previousState = this.history.pop();
    if (previousState) {
      this.state = previousState;
      this.render();
    }
  }
}

Com esse padrão, implementar "Desfazer" (Undo) ou um "Depurador de Viagem no Tempo" completo torna-se trivial. Você pode apenas armazenar um array de estados e saltar entre eles.

Recapitulação

Neste ponto, temos uma única classe como fonte da verdade para o estado e a lógica do app. Aplicamos mudanças reatribuindo um objeto de estado imutável, e o dispatch atua como o loop central.

Isso deve te dar uma visão prática de como a Elm Architecture funciona de uma maneira mais estruturada e orientada a objetos.

No próximo artigo, construiremos sobre isso com exemplos mais realistas (input de formulário, dados assíncronos e padrões de composição).