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).