This project is a deliberate, minimal exploration of The Elm Architecture (TEA) implemented in Svelte — not by mimicking Elm’s APIs, but by preserving its core ideas while respecting Svelte’s strengths and constraints.
The result is an architecture that is:
This project is intentionally opinionated.
It aims to demonstrate:
What it does not aim to do:
At a high level, the app is structured around five concepts:
This should feel familiar if you know Elm — but the way these pieces are wired is intentionally Svelte-native.
The Model represents the entire application state at a point in time.
type Model = {
selectedAnimal: SelectedAnimal
animals: Animal[]
remoteFetchStatus: RemoteFetchStatus
}
There is exactly one live model at runtime:
let model = $state<Model>({...})
This model always represents now.
Msg)All state changes flow through explicit messages.
type Msg =
| { kind: 'UserSelectedAnimal'; animal: SelectedAnimal }
| { kind: 'UserClickedGetNewAnimal' }
| { kind: 'UserClickedRemoveLast' }
| { kind: 'UserClickedRemoveAll' }
| { kind: 'RemoteFetchSucceeded'; animal: Animal }
| { kind: 'RemoteFetchFailed'; error: string }
Messages are:
No state changes happen outside a message.
All application logic lives in a pure function:
function computeNextModelAndCommands(msg: Msg): [Model, Cmd[]]
This function:
This mirrors Elm’s update function in spirit.
One deliberate choice in this architecture is the absence of if, switch, and nullish checks in core application logic.
Instead, all branching is expressed through pattern matching using match and matchStrict from CanaryJS.
This shifts control flow from statements to data.
Traditional conditional logic often leads to:
In contrast, pattern matching makes control flow explicit, exhaustive, and data-driven.
match: Open, Intentional Fallbacksmatch is used when a fallback branch is acceptable or intentional.
match(state, {
SomeCase: (s) => { ... },
AnotherCase: (s) => { ... },
_: (s) => { ... } // explicit fallback
})
Key properties:
kind_ case is explicit, not implicitThis makes “default behavior” a conscious design decision.
matchStrict: Exhaustiveness as a ConstraintmatchStrict is used when all cases must be handled.
matchStrict(msg, {
UserClickedGetNewAnimal: (m) => { ... },
UserClickedRemoveLast: (m) => { ... },
UserClickedRemoveAll: (m) => { ... },
})
Key properties:
Msg forces all consumers to updateThis turns exhaustiveness into a structural guarantee, not a convention.
Because all state transitions flow through typed messages and pattern matching:
if (x != null)The shape of the data is the control flow.
Pattern matching aligns naturally with SvelteTEA’s goals:
By replacing conditionals with pattern matching, the architecture becomes:
Control flow stops being a guessing game and becomes a property of the data itself.
Cmd)Commands describe what should happen, not how it happens.
type Cmd =
| { kind: 'FetchAnimal'; url: string }
Commands are data — not promises, not callbacks, not effects.
The only impure function that is allowed to advance application state is processMessage.
function processMessage(msg: Msg) {
const { nextModel, nextCommands } = computeNextModelAndCommands(msg)
model = nextModel
nextCommands.forEach(executeCommand)
frames = [
...frames,
{
msg,
nextModel,
nextCommands,
},
]
frameIndex = frames.length - 1
}
This function is responsible for:
This is the architectural heart of the app.
One of the primary benefits of this architecture is that the entire lifecycle of the application is explicit, predictable, and unidirectional.
There is exactly one way the app moves forward in time.
An event originates from one of two places:
In both cases, the event is immediately translated into a Msg.
Nothing else is allowed to affect state.
Every Msg flows into the same entry point:
processMessage(msg)
This function is the gatekeeper of time.
If state changes, it happens here — and only here.
Inside the runtime boundary, the message is handed to the pure update logic:
computeNextModelAndCommands(msg)
This step:
Given the same inputs, it always produces the same outputs.
The live model is replaced wholesale:
model = nextModel
There is no mutation, patching, or partial updates.
Time advances by replacement, not modification.
Any returned commands are executed by the runtime:
nextCommands.forEach(executeCommand)
Effects:
MsgThis keeps side effects honest and observable.
Each processed message produces an immutable frame:
{ msg, nextModel, nextCommands }
History is:
The past is a fact, not a function.
The UI renders from a derived value:
visibleModel
Rendering:
Time travel simply selects a different snapshot to project.
Because state only moves in one direction:
View → Event → Msg → Update → [Model, (Commands → Msg)] → View
The system gains:
There are no shortcuts, side channels, or hidden control flow.
Time only moves forward — and when you look back, you see exactly what occurred.
At its core, this architecture treats the application as data evolving over time.
Instead of thinking in terms of mutable state, it helps to think in terms of frames — discrete, immutable snapshots that capture what happened and what the app became as a result.
Each frame contains three things:
┌───────────────┐
│ Frame │
├───────────────┤
│ Msg │ ← what happened
│ nextModel │ ← what the app became
│ nextCommands │ ← what should happen next
└───────────────┘
Over time, the app becomes a sequence of these frames:
Frame 0 ──► Frame 1 ──► Frame 2 ──► Frame 3 ──► …
Each arrow represents one message being processed.
Visually, this looks like:
[ Msg₀ ] ─► [ Model₀ , Cmd₀ ]
↓
[ Msg₁ ] ─► [ Model₁ , Cmd₁ ]
↓
[ Msg₂ ] ─► [ Model₂ , Cmd₂ ]
↓
(current)
Important properties of this timeline:
Thinking in frames makes several things explicit:
This is why time travel in this architecture is safe, honest, and deterministic.
The app is not a black box. It is a timeline of facts.
Every processed message produces a frame:
type Frame = {
msg: Msg
nextModel: Model
nextCommands: Cmd[]
}
Frames are stored immutably:
let frames = $state<Frame[]>([])
Each frame represents:
“What the app looked like after this message.”
These snapshots power the debugger.
$inspectThis project intentionally leverages Svelte’s dev-only $inspect rune to make the internal execution of the architecture visible during development.
Each recorded frame is logged to the console in a structured, readable format, showing:
MsgThis allows you to observe the exact progression of application state over time, frame by frame.
Because $inspect is:
…it fits naturally into the architecture without compromising purity or runtime behavior.
$inspect Works So Well HereThe architecture already guarantees that:
$inspect simply surfaces that structure.
Rather than logging ad hoc values, the debugger logs meaningful architectural events — the same frames used by the time travel debugger.
This makes the console output a first-class debugging tool, not an afterthought.
Because $inspect is stripped in production builds:
The instrumentation exists purely to aid understanding during development.
In other words, the same code that powers time travel also powers the console — and disappears cleanly when it’s no longer needed.
Time travel is observational only.
The key derived value is:
let visibleModel = $derived<Model>(
frameIndex === frames.length - 1
? model
: frames[frameIndex].nextModel
)
The UI renders from visibleModel, not from model.
This keeps:
Replaying effects:
Instead, this architecture treats effects as:
“Things that happened once — and are now facts of history.”
This mirrors real systems and event sourcing principles.
Elm enforces its architecture at the language level.
This project:
You can violate the rules. You can also see exactly when you do.
This architecture shows that:
You don’t need Elm to think in The Elm Architecture.
You need:
Svelte becomes the host, not the boss.
This project is for developers who:
If that’s you — welcome.
This architecture is guided by a small set of intentional constraints:
MsgThese rules are not enforced by the framework. They are enforced by discipline, structure, and visibility.
Breaking them is possible — and obvious.
This approach is intentionally not a universal solution.
It may not be ideal for:
The tradeoff is deliberate:
clarity, debuggability, and temporal correctness over raw convenience.
At its core, the system moves in one direction:
User Action
↓
Msg
↓
computeNextModelAndCommands
↓
[ nextModel, Cmd[] ]
↓
Model Replacement
↓
Command Execution
↓
New Msg (optional)
There are no shortcuts. There is no hidden control flow. Every transition is explicit.
As applications grow, this architecture scales by:
Msg into domain-specific message unionsThe core rule never changes: state changes through messages, and time flows forward.
This project demonstrates an opinionated, Svelte-native interpretation of The Elm Architecture.
For clarity and reference, this approach is referred to as:
SvelteTEA (pronounced “Sveltey”)
SvelteTEA is not a framework or a library, it is a set of architectural commitments.
If you follow the rules, you get:
If you break the rules, you can see exactly where — and why.