Creating tools for Developers

DATE 2025-08-14 -- TIME 11:03:01

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