DEV Community

Cover image for Loom: A component framework for Go.
Anatole Lucet
Anatole Lucet

Posted on

Loom: A component framework for Go.

After four months in the making, I'm really excited to announce loom.
The first signal-based component framework in Go, that can be used for the Web, the Terminal, and more.

func Counter() Node {
    count, setCount := Signal(0)

    go func() {
        for {
            time.Sleep(time.Second / 30)
            setCount(count() + 1)
        }
    }()

    return P(Text("Count: "), BindText(count))
}
Enter fullscreen mode Exit fullscreen mode

What is loom?

Loom is a component framework.
It's similar to modern versions of SolidJS or SvelteJS, but in Go and with a few twists:

1) Markup is just Go functions.

Markup is not written in HTML, using templating, or in a separate JSX-like syntax that would require extra tooling.
Instead, it's just plain Go.

A component is simply a function that returns a loom.Node, optionally taking children as arguments.

func MyComponent(children ...loom.Node) loom.Node {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Since markup is just Go functions, it can be used and written however it fits you best to construct a complete UI.

For instance creating a Card() component with a title and a body:

func Card(title string, body loom.Node) loom.Node {
    return Div(
        H2(Text(title)),
        body,
    )
}

func App() loom.Node {
    return Div(
        Article(Card("A blog post", Text("A description."))),
        Article(Card("Another blog post", Text("Another description."))),
    )
}
Enter fullscreen mode Exit fullscreen mode

2) Concurrency is a first class citizen.

Signal-based reactive models have been relying on global state and predictible tasks scheduling to capture signal reads. Making it impossible to work with signals across multiple threads or concurrent code execution.

Loom's reactive model solves this issue while still keeping
the consistency and reliablilty of modern signal-based reactive models.
With this model you can update signals from hundreds of concurrent tasks, or create and destroy effects/memos across any number of goroutines and threads without any risk of polution.

get, set := Signal(0)

Effect(func() {
    fmt.Println("Changed:", get())
})

go func() {
    // triggers the effect as it should.
    // no risk of another signal sneaking in and poluting the effect.
    set(10)
}()
Enter fullscreen mode Exit fullscreen mode

3) Its not tied to any plateform.

Loom is not tied to the web, or the terminal, or native.
Because by itself, it doesn't have any UI concept like elements, styling and positioning.
Loom only provides the reactive model and basic -- arithmetic -- components like For(), Show() and Fragment().

The rest is provided by plateform-specific renderers which comes with components and tools for that plateform.

On its own, loom has no understanding of what a renderer is
Because a renderer does not integrate with loom. Instead you use it alongside loom's reactive model and base components.

There's two official renderers:
[*] LOOM-TERM -> | For building Terminal UIs.
[*] LOOM-WEB -> | For building Web SPAs.

import (
    "github.com/loom-go/loom"
    "github.com/loom-go/term"

    // importing every components from the term renderer
    . "github.com/loom-go/term/components"
)

func App() loom.Node {
    // using the Box() and Text() components from the term renderer
    return Box(Text("Hello World!"))
}

func main() {
    app := term.NewApp()
    app.Run(term.RenderFullscreen, App)
}
Enter fullscreen mode Exit fullscreen mode

4) Reactivity is explicit.

Most modern reactive framework have implicit reactivity.

When you use a signal in your markup, changes to that signal are automatically reflected to the proper element.

Loom takes a different approach. Reactive changes to the tree must be explicitly defined by the user.

It might seem extra tedious at first, but I promise it's not.

Explicit reactivity (or binding) gives you more control over the tree and how it reacts to changes.

count, setCount := Signal(0)

return P(
    Text("Reactive count: "),
    BindText(count), // text updates each time the signal changes

    Text("Unreactive count: "),
    Text(count()), // text does not update and only shows initial value
)
Enter fullscreen mode Exit fullscreen mode

Showcase

here

What next?

loom is still in very very early development.

Expect bugs, but also expect more stability and more features to come!

This initial releases sets the stage for what's to come.

It proves the idea works and is actually worth pursuing.

The coming weeks/months of development are going to be targeted towards higher stability of the framework,
and better documentation to make loom more accessible for a broader audience.

The next core features you should expect to land are going to gravitate around reactive asynchronicity --
a very important step towards proper: fetching, computations, or anything blocking.

Async tasks are completely possible in the current version of loom, but they could be better.

One of the first building blocks in that direction is async memos.

Put simply, an async memo is just a reactive computation that's executed in a goroutine.

userID, setUserID := Signal(0)

user := AsyncMemo(func() (User, error) {
    // runs in a goroutine each time the userID signal changes
    return getUser(userID())
})

// use it like a regular signal, but with a possible error
u, err := user()
Enter fullscreen mode Exit fullscreen mode

Async memos will open the door for more advanced features like suspense, panic boundaries, and async stores similar to TanStack Query.

If you'd like to help, head over at github.com/loom-go!


If you made it here you're probably intrested in how to get started with loom! Head over to the -> DOCS to learn more.

If you have a question or want to discuss something about loom:
come and join the Discord. There's no such thing as a bad question!

Top comments (0)