Description
Time to talk about Views // Extra
I’m going to talk about Extra’s declarative view system. “Declarative” is bandied about a lot these days, because many frontend frameworks claim to be declarative. I say “claim” because there are all sorts of caveats, and I believe that these caveats are avoidable. But you have to go big. I do not think that React, Svelte, Solid, Ember, Angular, or even beloved Elm went far enough.
You know who nailed declarative? Spreadsheets. Visicalc, Lotus 1-2-3… there are probably more but none come to mind. Spreadsheets can render thousands of components (they call them “cells and charts” for some reason) very quickly. Let’s aim for that!
I’m going to take the slightly long way, because I never answer a question in ten words when a hundred will suffice.
The Basic Types
Real quick, because I’ve covered most of this before. Extra has null
, Boolean
(true
/false
), Float
and Int
(Int
is a subtype of Float
,
btw, because Float
is a superset of all Int
values), and String
. I’ll call
these the “basic types”.
It also has Regex
and will (though it doesn’t at the time of this writing)
have a Date
type, and probably others in an upcoming standard library, File
and whatnot.
The Container Types
To really get work done you need to be able to have collections, and for that we
have Array<T>
, Dict<T>
, Set<T>
, and Object
. You can spot there that Object
is the odd-one-out, it is not generic over its contents, it is what we
call in the business a “Product Type”, I might also call it “heterogenous” as
opposed to the other types there being “homogenous”.
In simple terms: every item in an Array is of the same type… even if that type
is a “one-of” type like String | Int
.
someThings: Array(String | Int) = ['hi', 2, 'bye', -1]
-- we could, if we wanted, be really specific
someThings: Array('hi' | 'bye' | Int(-10...10)) = ['hi', 2, 'bye', -1]
-- or even more specific! This is the inferred type, if we left off the
-- type signature altogether:
someThings: Array('hi' | 'bye' | 2 | -1, length: =4) =
['hi', 2, 'bye', -1]
-- contrast this with an Object type, which has a bunch of properties, each with
-- their own type. Object types in Extra can mix positional properties (aka a
-- `Tuple`) with named properties (maybe we call this a `Record`, doesn't matter
-- what you call it because Extra merges them together)
type User = {
name: String
age: Int(>=0)
}
We’re almost there, I promise!
Classes
With the above types, we have enough to implement a ton of really useful
programs, like a web server that serves static content, or a sed
like replacement. But it’s not enough
to make an app.
Your application will, inevitably, need to maintain state, and the types above
are all immutable. Enter class
. (did I say this would be a blog post about
views? yeah yeah I’m getting there)
Classes in Extra are the things that can have state, but don’t get too excited, they still aren’t mutable, not in the usual sense. Remember, I said that when it comes to “being declarative”, you have to go big, and going big here means that mutations also need to be declarative, they can’t just happen willy nilly! But first, here’s what it looks like to have a class with state. The venerable Todo list:
type Todo = {title: String, isComplete: Boolean}
class TodoList {
@todos: Array(Todo) = []
fn addTodo(# title: String) =>
@todos.push({title:, isComplete: false})
fn complete(# title: String) =>
let
index = @todos.index(of: fn(t) => t.title == title)
in
@todos[index].isComplete = true
}
This… just looks like regular ol’ code with mutable state, am I lying or something about “declarative” mutations? Yes! Er, NO! No, not lying. If you were writing tests for this class, you might be tempted to test it the way we do in classical, mutable languages:
todoList = TodoList()
todoList.addTodo("buy milk")
todoList.addTodo("get to the point")
todoList.complete("buy milk")
expect(todoList).to(...)
This won’t work, though. Because classes are not mutable, and functions cannot have side-effects. So what’s a girl to do!?
import Testing : {resolve, expect}
let
todoList = TodoList()
|> resolve(#.addTodo("buy milk"))
|> resolve(#.addTodo("get to the point"))
|> resolve(#.complete("buy milk"))
in
expect(todoList).to(.equal, TodoList(todos: [
{title: "buy milk", isComplete: true}
{title: "get to the point", isComplete: false} -- rude
]))
Views
Finally! Hopefully you get a sense for where this is going… let’s just dive in:
type Todo = {title: String, isComplete: Boolean}
view TodoList {
@todos: Array(Todo) = []
@editingTitle: String = ""
fn updateEditing(# value: String) =>
@editingTitle = value
fn save() =>
guard(
@editingTitle -- validate that the title is not ""
else:
null
):
@(
@todos.push({title: @editingTitle, isComplete: false})
@editingTitle = ''
)
-- umm tbh, I am not yet sure what the syntax will be
-- for a "block" of multiple mutations... HMU in September.
render() =>
<main>
<div class='todos'>
{@todos.map(fn(t) => <Todo todo=t />)}
</div>
<div class='todo-editor'>
<input type="text" value=@editingTitle onChange=updateEditing />
<button onClick=save>Save</button>
</div>
</main>
}
First render
This one is easy - the todo list renders, pretty much exactly as it would in any frontend framework.
Editing the todo list
While editing, we see that the <input />
element is responsible for updating
the text of the new todo item. But what happens during a render?
In React, for sake of comparison, and because it’s not bullying if you’re
picking on the biggest kid in school (this is a lie, bullying is bullying, and
React is amazing, if you can’t get my humour then I’m very sorry), would
“trigger re-renders” due to a call to setEditingTitle
, and we would use the useState
hook for this. And because we have our entire app in one component,
the entire app would re-render. We would go down a rabbit hole of performance
optimization, like refactoring out the editor, not because we want the code
separated (maybe we do, I’m not opposed) but because it will have a significant
performance impact.
Likewise when we press “add”, which appends a new item to the @todos
array.
This change is even more insidious, because a new array instance must needs be
created. Even if you were using Signals in React (you should), or immerjs,
adding an item to an array inevitably means you have to re-render the list of
todo items. So you should… memoize them? Use React.memo
? Or will the
internet be angry at you if you do that… is React.memo
still in vogue? Or
no, we should let the React Compiler fix this fox us. Don’t bother understanding why, just trust React Compiler to rewrite your code for you!
NO
Bad. This is Very Bad Design. The framework and language should make life easier, it shouldn’t hand you a footgun and then blame you when you shoot your toes off.
The Way of Extra
Let’s talk about the return value of @todos.push(# item: Todo)
. It’s return
type is &Msg
. What, T.F., is &Msg
. The &
sigil indicates that it is a
side-effect, and Msg
is a smile-and-a-nod to Elm. ‘Nuff said!
The render()
method (and all view
functions, there is a stateless variant
that I’ll show below) is invoked by the runtime in such a way that it not only
does a first render pass, it also monitors for changes. The runtime also
provides a send
function to all components so that they can emit messages to
the system. Above, I introduced a resolve
method in our testing pseudo-code
without drawing much attention to it - but it serves the same function.
If you’re familiar with a reducer, this should kinda look familiar, except
reducers in the React and Redux sense were limited to only handling
state-change-side-effects, and all other side effects were left as an exercise
to the reader (spoiler: Redux got it wrong). Elm got much closer: state changes
are “special”, and all other side-effects are handled via a Cmd
type. Extra
has just one side-effect primitive. Maybe it’s synchronous maybe it’s async, you
shouldn’t have to concern yourself too much with the distinction. The reason
Extra can get away with just one Msg
type is because mutations became a
subset of Cmd
. But Msg
/Message is a better name because this is all actor
pattern kind of stuff, and actors use messages.
With send
in hand, the <button>
component can now emit a message to the
runtime, in this case the message instance will be Array.push(T)
. This message
will be passed down the component tree, looking for any elements that are
listening for this specific message.
The @todos.map
function is one of those listeners! Here’s where Extra goes way off script compared to other languages.
Most functions in Extra have two implementations: one for “imperative mode” (first render), and another in “listener mode”.
Take a second and think about the functional-reactive-programming introductory guides that you’ve read before. I bet they share something in common: I bet they require you to learn what FRP is. Screw that. Just write code, and let the compiler and runtime figure that stuff out! That’s the Extra way™.
So, @todos.map
receives this Array.push
message… it runs its callback
function with the new value, which returns a new <Todo />
component (<div class='todo'>etc</div>
). That component is then passed in another message to
the parent component (it’s another Array.push
message, though you could think
of it as DOM.insert
if you wanted to conceptualize it in a more useful way).
The <div>
is added as a child to <div class='todos'>
… and that’s it! We’ve
updated the DOM, with nary a “render and diff” in sight.
Mutations are messages, and Extra understands messages. This is the secret sauce. You could write your entire app as one massive single-file application, and you’ll never hit a ceiling in terms of re-renders. You could have a terminal CLI that pushes out 10k log messages, but adding that 10,001st message will be the same cost-to-render as the first.
Context and Children
On the subject of Views, let’s talk about a couple ideas I stole from React.
Context providers are a really easy way to dependency-inject globals/singletons
into your app, think user
and theme
objects that many components will need.
And obviously components form a free of parent/child relationships.
As always, Extra has more to say on these ideas.
Context
React Contexts are useful, but they are yet another foot gun! There is no compile-time guarantee that the context you need in your component will actually be available at runtime. “Be careful” seems to be the advice du jour, and even the venerable React Compiler isn’t stepping in to help out.
No thank you. I’ll take compile time guarantees, please and thank you.
view Account(
context: Context
) {
-- it looks like Context is defined second, but it's available to the constructor
type Context: {
user: User
}
render() =>
<>{context.user.name}</>
}
This syntax declares that the Account
view requires a context of type {user: User}
. If it is not provided, the program will not compile.
view App(
children: Children
) {
@user: User?
fn updateUser(# user: User) => @user = user
render() =>
if (@user) {
then:
<Context user=@user>{children}</Context>
else:
<Login onLogin=updateUser />
}
}
If you want to host <Account />
, but your component doesn’t provide user:
context, you’ll have to add it to your component’s Context
type as well.
view Navigation(
context: Context
) {
type Context: {
user: User
theme: Theme
}
{- option #2, derive the type
type Context: {
theme: Theme
} & Account.Context
-}
{- option #3, the compiler could *infer* Context by investigating all
the child components... I'll add this to the pile of TODOs. -}
render() =>
<nav class=context.theme.navClass><Account /></nav>
}
Children
The children
prop works just like it does in React. Psyche! It’s way better.
You can have type checking on children
!
view TextEditor(
children: Array(String)
) {
render() =>
<textarea>{children}</textarea>
}
<TextEditor>
{- strings are allowed -}
this is fine
</TextEditor>
<TextEditor>
{- but not Html.Span -}
<span>compile-time-error</span>
</TextEditor>
The types you can express there might not be what you expect, though… because views are, in their heart of hearts, functions, they return Components without actually being Components. What I mean is:
<div> -- this is a component of type Html.Div
view MyDiv(children: Children) {
render() =>
<div class="my-div">{children}</div>
}
-- MyDiv is a `view`, whose *return-type* is `Html.Div`. Aka `View<Html.Div>`.
-- (Actually it's `View<Html.Div, &Msg>`)
-- (haha I lied it's actually `View<Html.Div, &Msg, Context, Children>`)
view StackOfDivs(children: Array(Html.Div)) {
render() =>
<div class="stack-of-divs">{children}</div>
}
<StackOfDivs>
<MyDiv> -- this works, because MyDiv returns a <div>
You say hello!
</MyDiv>
<div>
And I also say hello. Hello, hello.
</div>
</StackOfDivs>
Props
I’m hopefully putting the obvious and boring stuff at the bottom (and the most important bits… in the middle? Should I be writing at all!?).
The astute noticed that props are declared on the render
function, alongside
the very special props context
and children
:
view Alert(urgency: 'high' | 'medium' | 'low') {
-- not a typo, you can elide the parentheses on 'render'
render =>
<div class="urgency-$urgency">{children}</div>
}
View functions
React did do something right when they introduced functional components. Good artists steal, right? 😏
view Alert(urgency: 'high' | 'medium' | 'low') =>
<div class=("urgency-$urgency")>{children}</div>
view
functions support context and children, but they don’t support state.
Hooks are an abomination, at-me in the comments.
view Alert(
context: {theme: Theme}
children: String
urgency: 'high' | 'medium' | 'low'
) =>
…
Invoking a view
views are, in their heart of hearts, functions
Views are implemented as classes, with the caveat that they are “renderable”. Classes and views are both invocable, and in both cases they return an instance of the class (or view)
view Header {
@title: String = title
render =>
<header>
<h1>{title}</h1>
</header>
}
let
header = Header(title: 'foo')
in
{
header.title -- 'foo'
header -- instance of Header
}
So it’s important to note that <Header title='' />
is a very different thing than Header(title: '')
. Those brackets are doing a lot of work.
On first render, they invoke the constructor, and place the view instance in the
rendering tree, and then invoke the render
function with a send
callback
bound to the component. When an even sends a message, the payload and component
are sent to the runtime, which applies the mutation and sends the message down
the component tree. (and here we take a page from React - we start the message
passing at the component that initiated the message)
This is all pretty much a repetition of what was said above, but I wanted to
point out this distinction, because it’s good to know that these View
instances get created and destroyed. There’s a little more to it, because the
code itself also creates nodes in the rendering tree (the return value from
“listener mode” is a node that expects messages).
Published: 2025-08-13