INTRODUCING LOOM
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))
}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 {
// ...
}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."))),
)
}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 reliability 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 pollution.
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 polluting the effect.
set(10)
}()3) It’s not tied to any platform.
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 platform-specific renderers which comes with components and tools for that platform.
But on its own, loom has no understanding of what a renderer is either. 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)
}import (
"github.com/loom-go/loom"
"github.com/loom-go/web"
// importing every components from the web renderer
. "github.com/loom-go/web/components"
)
func App() loom.Node {
// using the P() and Text() components from the web renderer
return P(Text("Hello World!"))
}
func main() {
app := web.NewApp()
app.Run("#root", App)
}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
)Showcase
counter
A very simple counter built with LOOM-TERM:
// define your component
func Counter() Node {
count, setCount := Signal(0)
// start a new goroutine when the component is mounted
go func() {
for {
time.Sleep(time.Second / 30)
setCount(count() + 1) // update the signal inside the goroutine
}
}()
// return some markup
return P(Text("Count: "), BindText(count))
}
func main() {
app := term.NewApp()
// render the component using the terminal renderer
errs := app.Run(term.RenderInline, Counter)
}A very simple counter built with LOOM-WEB:
// define your component
func Counter() Node {
count, setCount := Signal(0) // define a signal with a getter and setter
// start a new goroutine when the component is mounted
go func() {
for {
time.Sleep(time.Second / 30)
setCount(count() + 1) // update the signal inside the goroutine
}
}()
// returns some markup
// (here using the web renderer's P() and Text() components)
return P(
Text("Count: "),
BindText(count), // render the count signal as text. reactivity is explicit
)
}
func main() {
app := web.NewApp()
// render the component using the web renderer
errs := app.Run("#root", Counter)
}With goroutine cancellation
func Counter() Node {
count, setCount := Signal(0)
go func(self Component) {
// stop the loop when the component is diposed
for !self.IsDisposed() {
time.Sleep(time.Second / 30)
setCount(count() + 1)
}
}(Self())
return P(Text("Count: "), BindText(count))
}See Self().
---
---
console
An example of using the built-in Console() component from LOOM-TERM:
Full source code at term/examples/console.
---
styling
An example of reactive styling and hover state with LOOM-TERM:
Full source code at term/examples/styling.
What next?
loom is still in very very early development.
Expect bugs, but also expect more stability and more features to come!
This initial release 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()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!
CREDITS
The fundamentals behind loom’s reactive model rest on the hard work done by Ryan Carniato, Milo Mighdoll and others pushing the limits of signal-based reactive systems.
Loom’s reactive model started off with a heavily inspired version of what’s been done for the upcoming SolidJS V2 model.
The LOOM-TERM renderer also rests on the hard work done by Kommander and the OpenCode team on their very cool native renderer. Many of the ideas behind LOOM-TERM where inspired by what’s been done on OpenTUI.
If you made it here you’re probably interested 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!