This is the first part of a series of articles about writing a TEA-based framework in TypeScript.
The Elm Architecture is used in many places: Lustre in Gleam, Bubble Tea in Go, and Iced in Rust.
So let’s learn by creating a small project using The Elm Architecture, which we can later migrate to our own TypeScript framework. After all, there are not enough JavaScript frameworks for the web.
You don’t need prior Elm knowledge, but you should have a basic understanding of TypeScript and DOM events.
We can start with a simple Vite project, using the Vanilla template and TypeScript:
❯ pnpm create vite
│
◇ Project name:
│ tea
│
◇ Select a framework:
│ Vanilla
│
◇ Select a variant:
│ TypeScript
│
◇ Install with pnpm and start now?
│ Yes
For this article we are going to use lit-html so we don’t have to implement two key features ourselves: HTML generation and efficient rendering.
We can create templates with JavaScript interpolation by using the html tag (for example, ${...} inside the template), and we can bind DOM events with @event syntax, similar to Vue, and conceptually similar to onEvent props in React.
Tip
If you want syntax highlighting inside html template literals, install the VS Code plugin for Lit.
pnpm add lit-html
We remove everything in main.ts and write a basic view for our 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);
We render into #app using lit-html. Next, let’s make this dynamic using interpolation:
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);
Now, let’s make a function that increases the counter and bind it to the button:
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++;
}
But this won’t work. Why?
When increaseCounter runs, the view was already created and rendered. We update counter, but we don’t create a new view or render again.
Let’s fix that by making view a function that receives state, and re-rendering after each update:
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);
}
Now clicking the button updates the UI again.
At a high level, this idea appears in many frameworks: UI as a function of state. The details differ (Virtual DOM, fine-grained reactivity, compile-time optimizations), but the core loop is familiar.
Now we’ll apply the core ideas from The Elm Architecture but encapsulated in a class, similar to how the iced library works in Rust.
The Counter Application
Instead of loose functions and variables, we’ll encapsulate our application logic in a class. This class will hold our application state and define how to update and view it.
First, let's define our state and messages:
type State = {
value: number;
};
type Message = "Increment" | "Decrement";
Now, let's create the Counter class:
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();
Why avoid mutation?
In the update method, notice we didn't do this.state.value++. Instead, we created a whole new object: this.state = { ...state, value: state.value + 1 }.
By reassigning the state instead of mutating its properties, we preserve the previous state in memory (as long as something holds a reference to it). This is the foundation for features like Time Traveling.
Time Traveling
Because our update logic is based on transition from one immutable state to another, we can easily keep track of every state our application has ever been in.
Imagine adding a history array to our class:
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();
}
}
}
With this pattern, implementing "Undo" or a full "Time Travel Debugger" becomes trivial. You can just store an array of states and jump between them.
Recap
At this point, we have a single class as the source of truth for app state and logic. We apply changes by reassigning an immutable state object, and dispatch acts as the central loop.
This should give you a practical overview of how The Elm Architecture works in a more structured, object-oriented way.
In the next article, we’ll build on this with more realistic examples (form input, async data, and composition patterns).