GO-Lander is an experimental library to help you build frontend applications with the power of Golang and Web Assembly (WASM). It is heavily inspired by JavaScript frontend libraries such as React and aims to provide the smallest footprint possible.
GO-Lander is very unstable and not tested. If you decide to use it in a production application, please be aware that the API might change as we work on it. The lack of tests also means that there are likely undetected bugs in the diffing and mounting algorithms, which could cause issues in your apps. Please check the example directory to see what kind of behavior we currently support.
GO-Lander was built as a passion project and a way to understand the Golang WASM implementation. It started as a clone of Vugu, with the goal of implementing an API closer to React's and experiment with the virtual DOM, alongside lander.
It evolved in a genuine attempt to offer a different experience from the existing libraries, but it still retains its experimental and passion project nature. Our goal is not to become the next replacement for React, but rather to offer something people can experiment with.
You shouldn't, it's far too unstable and buggy in its current state. But if you decide to use GO-Lander, the library has a few things to offer:
- Lightweight. It is intended to have the lightest footprint possible in your application, the APIs are straightforward and the amount of features has been purposefully kept to a minimum. Any additional "nice-to-have" features can be ignored.
- Native. We're using the native features of Golang to our advantage to provide a near native application experience, you should feel right at home. Rather than to clone React in Go, we've attempted to provide an experience as close to React as possible without moving away from the experience of writing Go code, especially when it comes to React's more "magical" features (I.E. features that work even though the common JavaScript knowledge would say otherwise).
- Component-centric. Components can be composed to create large applications, giving you the ability to separate the concerns of your application in individual components, like you would for any other Golang application.
- Expandable. Our goal is to keep the library simple and open, we give you the base tools to build anything you'd like. None of the "advanced/experimental" features such as our router use internal logic, they are all build with the same public APIs.
Vugu is a lot more production ready and mature than GO-Lander. Vugu is a lot closer
to Vue.js, it expects you to write .vugu
files and your application logic in an HTML script
tag. If you enjoy the
Vue.js experience, Vugu is likely to be much more usable. Since GO-lander is closer to vecty in terms of its
developer experience, we recommend looking
at its own comparison to Vugu to make your decision.
Vecty is closer to how GO-lander was designed with some key differences. Vecty
expects components to always be struct pointers with a Render
method. GO-Lander allows you to choose how you want
to structure your app, we store components as functions, regardless of whether they are methods of a struct or not.
Vecty
also differs from GO-lander in the algorithm it uses for diffing. Vecty uses a high-performance algorithm similar to
the virtual DOM pattern where Go-lander uses a pure virtual DOM tree with a separate diffing and patching process.
Install the latest version of GO-lander to get started.
go get github.com/Minivera/go-lander@latest
Once installed, create a main file for your application, let's start with a "Hello, World!".
package main
import (
"fmt"
"github.com/minivera/go-lander"
"github.com/minivera/go-lander/context"
"github.com/minivera/go-lander/nodes"
)
func helloWorld(_ context.Context, _ nodes.Props, _ nodes.Children) nodes.Child {
return lander.Html("h1", nodes.Attributes{}, nodes.Children{
lander.Text("Hello, World!"),
}).Style("margin: 1rem;")
}
func main() {
c := make(chan bool)
_, err := lander.RenderInto(
lander.Component(helloWorld, nodes.Props{}, nodes.Children{}), "#app")
if err != nil {
fmt.Println(err)
}
<-c
}
For a web assembly application to stay alive after its first execution, we need to create a channel and wait on it. This ensures our application stays alive until the page is refreshed, which allows GO-lander to rerender the app on demand.
We then call the RenderInto
function using a component called helloWorld
on the #app
DOM node, which we'll
create later. GO-lander apps need to start with a component, which can then render any structure you want.
In this case, the helloWorld
component is a function that renders a h1
tag with the text "Hello, World!" and
some styling. Any styles you add to an HTML node with Style
will be added to a global stylesheet and updated on
every subsequent render.
GO-lander does not provide any utilities to compile your application to Web Assembly, so we'll also need an index. html
file and the JavaScript file provided by your installation of Go. You may also use [wasmserve
]
(https://github.com/hajimehoshi/wasmserve) to simplify this process.
First, we need an HTML file. We can copy the one from the official [Web Assembly wiki](https://github.
com/golang/go/wiki/WebAssembly#getting-started). Add a <div id="app"></div>
tag to the body, the final file should
look like this.
<html>
<head>
<meta charset="utf-8"/>
<script src="wasm_exec.js"></script>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
go.run(result.instance);
});
</script>
</head>
<body>
<div id="app"></div>
</body>
</html>
Then, copy the official Go JavaScript glue file to your current directory, this is needed to bridge the gap between GO-lander and the JavaScript DOM environment.
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .
With all that in hand, compile your application to WASM and serve it.
GOOS=js GOARCH=wasm go build -o main.wasm
# install goexec: go get -u github.com/shurcooL/goexec
goexec 'http.ListenAndServe(`:8080`, http.FileServer(http.Dir(`.`)))'
Open your browser to http://localhost:8080
and you should see the "Hello, World!" message. To deploy your
application, upload the main.wasm
, index.html
, and wasm_exec.js
to any server capable of serving HTML files.
For more advanced examples, check out the example directory. We use mage
to build and run the
examples, install it with:
go install github.com/magefile/mage@latest
Our magefile
contains all build targets, feel free to check them out using the command mage
in this directory.
To run any example, use this command and replace <example name>
with the directory name of the example you want to
run.
mage runExample <example name>
You can also run all examples in a single app using:
mage runExampleViewer
Any GO-lander starts with the lander environment, which is created when you render an application into a DOM node. This environment takes care of the entire render and diffing process, but you are responsible for keeping it alive and handling any updates.
env, err := lander.RenderInto(someComponent, 'Some DOM node selector')
When this function is called, lander will query for a DOM node in the document using the provided selector and mount
your component into it. RenderInto
can only render components, every GO-lander app must start with a component.
Once properly mounted, the lander environment is returned. If any error happened (such as the DOM node not being
found or the library running in a server environment), an error will be returned alongside a nil
environment.
The environment exports a single method, Update()
. Whenever the state of your application changes, or you want to
trigger a rerender of your application, call env.Update()
. This will rerender your application, diff it against
the previous application, and update the DOM with the changes, if any.
Both Update
and RenderInto
are safe to execute in parallel, only one execution of either can run at a time.
Subsequent calls will need to wait until the current render is done before they can update the tree. This means that
any update you trigger are executed in order, but also that the entire tree must have been fully mounted before you
can update. Neither are batched, if Update
is called is quick succession, each update will execute sequentially.
To write the HTML structure of your app, use one of the three functions provided.
- lander.HTML(tag, attributes, children) will create an HTML node with the given tag, which can be any valid HTML tag, including web components.
- lander.SVG(tag, attributes, children) is an override of
lander.HTML
which adds thehttp://www.w3. org/2000/svg
to the node and creates it on the document using that namespace, which ensures that SVG tags will render properly. - lander.Text(text) will create a text node, which can be used to add text inside any node.
HTML nodes take a nodes.Attributes
map as their attributes parameter, which can include any valid HTML attribute
or property for the element. GO-lander will extract the attributes and assign them using the following rules:
package main
import (
"github.com/minivera/go-lander/events"
"github.com/minivera/go-lander/nodes"
)
lander.HTML('div', nodes.Attributes{
"checked": true, // Will be converted to `checked=""` on the DOM element if true and omitted if false.
"placeholder": "some string", // Will be kept as a string and assigned on the DOM element.
"value": -1, // Will be converted to as string and assigned on the DOM element.
"click": func (event *events.DOMEvent) error {} // Will be assigned using `addEventListener` on the DOM element.
}, nodes.Children{});
Any other type is ignored. If an attribute has an associated property on the DOM node (such as value
), it will
also be set as a property. See
the content vs. IDL attributes
reference for more details.
Event listeners must be defined as either a function following the type nodes.EventListenerFunc
or as a function
with the exact signature func(event *events.DOMEvent) error
. Event listeners are assigned with their HTML name,
see the event reference for a list of events and their names.
An event listener is called with a events.DOMEvent
parameter, which includes three helpful methods.
event.JSEvent()
returns the actual JavaScript event triggers as ajs.Value
.event.JSEventThis()
returns thethis
value of the event listener, if any.event.PreventDefault()
triggers the prevent default call on the event, which will block any of the event's default behavior, such as form submits.
HTML and SVG nodes may also take a slice of children. These children can be any of the node returned by the lander
node factories, such as component nodes, text nodes, fragment nodes, or other HTML nodes. The nodes.Children
type
is provided to reduce the complexity of the code when defining the slice of children.
lander.HTML
nodes provide some styling capabilities through their Style
and SelectorStyle
methods. Style
takes any valid CSS definition and will assign it to the HTML node on render, dynamically generating class names and
a CSS file to assign in the document's <head>
. Calling Style
multiple times will override the previous styling
definition. Rather, if you wish to further define your CSS styles, SelectorStyle
can be used to add styles with an
option selector, for example:
lander.
HTML('div', nodes.Attributes{}, nodes.Children{
lander.HTML('input', nodes.Attributes{}, nodes.Children{}).
}).
// Will generate a random CSS class and create this CSS style in the head
// .classname { color: red; margin: 10px }
Style("color: red; margin: 10px").
// Will use the previously generated CSS class and create this CSS style in the head
// .classname input { color: red; margin: 10px }
SelectorStyle("input", "width: 80%")
Components are the core of GO-lander's component pattern. In their simplest of forms, a component is a function that, given specific inputs, returns a single lander node.
Every component's signature must match the signature of nodes.FunctionComponent
, namely:
type FunctionComponent[T any] func (ctx context.Context, props T, children nodes.Children) nodes.Child
The context.Context
is covered in a later section. The component's Props
are, unlike HTML attributes, a generic
type that can be assigned any type. The nodes.Props
type if provided as a utility for components that take no
props. The properties are never converted by GO-lander, they will be passed along to the function. When GO-lander
renders the tree, it calls each component in the tree to generate its result (called a render).
You are responsible for writing your components in any way you want, GO-lander only provides you with a mean to return a child and have it appear in the updated DOM tree.
As an example, let's explore the "Hello, World!" example with some properties.
package main
import (
"fmt"
"github.com/minivera/go-lander"
"github.com/minivera/go-lander/context"
"github.com/minivera/go-lander/nodes"
)
type helloWorldProps struct {
message string
}
func helloWorld(_ context.Context, props helloWorldProps, children nodes.Children) nodes.Child {
message := props.message // Lander does not check the types, make sure you validate them yourself
return lander.Html("h1", nodes.Attributes{}, nodes.Children{
lander.Text(message),
children[0],
}).Style("margin: 1rem;")
}
func main() {
c := make(chan bool)
_, err := lander.RenderInto(
// `Component` is a generic function, the type is infered from the types of the props
lander.Component(helloWorld, helloWorldProps{
message: "Hello, World!"
}, nodes.Children{
lander.Text("From the lander README"),
}), "#app")
if err != nil {
fmt.Println(err)
}
<-c
}
When creating a component node through lander.Component
, the props
passed will be given to the component's
function, as will the children. If the component returns another component as part of its rendered node's descendant,
that component will in-turn be rendered until all components in the tree have been rendered. This is the GO-lander's
render cycle in a nutshell.
GO-lander does not provide any type safety for props, they are passed to GO-lander's internal logic without any type
information. You are responsible for checking the types of the values you receive, and to panic
if the types are
not correct or a required prop is missing.
Any component may return nil
instead of a nodes.Child
. The component's render result will be ignored and any
previous node it rendered will be removed. The component may return a valid node in a later render cycle, the result
will then be inserted into the DOM.
Returning only a single child from a component may not always be practical. If a component renders a slice of
children, for example, you would need to wrap that slice in an HTML node such as a div
to only return a single
child. GO-lander provides a final type of node to solve this problem, the lander.Fragment
node.
A fragment takes a slice of nodes as its children and will render them as if they were a direct child of the closest HTML node. Fragments are also very useful when you want to assign a slice of children to a node that already has children defined, in place where the spread operator does not work. Taking our "Hello, World" example from above, we could rewrite it with fragments like this:
package main
import (
"fmt"
"github.com/minivera/go-lander"
"github.com/minivera/go-lander/context"
"github.com/minivera/go-lander/nodes"
)
type helloWorldProps struct {
message string
}
func helloWorld(_ context.Context, props helloWorldProps, children nodes.Children) nodes.Child {
return lander.Html("h1", nodes.Attributes{}, nodes.Children{
lander.Text(props.message),
// But now the fragment will take care of the children
lander.Fragment(children),
}).Style("margin: 1rem;")
}
func main() {
c := make(chan bool)
_, err := lander.RenderInto(
lander.Component(helloWorld, helloWorldProps{
message: "Hello, World!"
}, nodes.Children{
lander.Text("From the lander README"),
lander.Text("This node would previously have been ignored!"),
}), "#app")
if err != nil {
fmt.Println(err)
}
<-c
}
Since GO-lander does not make any assumptions in how you want to structure your app, it also does not provide any in-depth state management solutions built-in. Rather, you can use the native features of Go to store state for your application with global variables or structs for example.
The most straightforward way of storing state is with struct components. To create a struct
component, define a
struct
type with the state values you want to manage, and create your component as a method of the struct
. As
long as the struct is kept alive in your application and managed, your state will be tracked. For example, let,s
build a log-in form.
package main
import (
"fmt"
"github.com/minivera/go-lander"
"github.com/minivera/go-lander/context"
"github.com/minivera/go-lander/events"
"github.com/minivera/go-lander/nodes"
)
type loginForm struct {
// We keep a reference to the environment to allow updating on form changes
env *lander.DomEnvironment
username string
password string
}
// The form should be a pointer
func (f *loginForm) render(_ context.Context, _ nodes.Props, _ nodes.Children) nodes.Child {
return lander.Html("div", nodes.Attributes{}, nodes.Children{
lander.Html("h1", nodes.Attributes{}, nodes.Children{
lander.Text("Log into our app"),
}),
// We would likely want to also handle the form submit here
lander.Html("form", nodes.Attributes{}, nodes.Children{
lander.Html("label", nodes.Attributes{
"for": "username",
}, nodes.Children{
lander.Text("Username"),
}).Style("font-weight: bold;"),
lander.Html("input", nodes.Attributes{
"name": "username",
"placeholder": "Enter Username",
// On change, we assign the value of username to the input value, then rerender
"change": func(event *events.DOMEvent) error {
f.username = event.JSEvent().Get("target").Get("value").String()
return f.env.Update()
},
}, nodes.Children{}),
lander.Html("label", nodes.Attributes{
"for": "password",
}, nodes.Children{
lander.Text("Password"),
}).Style("font-weight: bold;"),
lander.Html("input", nodes.Attributes{
"name": "password",
"placeholder": "Enter Password",
"type": "password",
// On change, we assign the value of password to the input value, then rerender
"change": func(event *events.DOMEvent) error {
f.password = event.JSEvent().Get("target").Get("value").String()
return f.env.Update()
},
}, nodes.Children{}),
lander.Html("button", nodes.Attributes{
"type": "submit",
}, nodes.Children{
lander.Text("Submit"),
}).Style("margin-top: 1rem;"),
}),
}).Style("margin: 1rem;")
}
func main() {
c := make(chan bool)
form := &loginForm{}
env, err := lander.RenderInto(
// Render the form's render method instead of a function component
lander.Component(form.render, nodes.Props{}, nodes.Children{}), "#app")
if err != nil {
fmt.Println(err)
}
form.env = env
<-c
}
In this example, the loginForm
struct
stored our two state value, and a pointer to the GO-lander environment,
which allows us to trigger an update when the form value changes. Since the form is created in the main
function,
it will be alive and keep our state for the entire lifecycle of the application.
A drawback of this approach is that any subsequent struct components will need to be stored either in the main
function, or as a property on another struct. This creates more management for your application's state, but it also
makes your state management less global and less "magical". For example, if our login form was a subcomponent
somewhere in the app, we could instead store it in another struct in a sort of tree of struct components.
package main
import (
"fmt"
"github.com/minivera/go-lander"
"github.com/minivera/go-lander/context"
"github.com/minivera/go-lander/events"
"github.com/minivera/go-lander/nodes"
)
type app struct {
// We keep a reference to the environment to allow updating on changes
env *lander.DomEnvironment
loginForm *loginForm
}
// The form should be a pointer
func (a *app) render(_ context.Context, _ nodes.Props, _ nodes.Children) nodes.Child {
return lander.Html("div", nodes.Attributes{}, nodes.Children{
lander.Component(a.loginForm.render, nodes.Props{
"onSubmit": func(username, password string) error {
// Do something
// Reset the form's state
a.loginForm = &loginForm{
env: a.env,
}
return f.env.Update()
},
}, nodes.Children{})
})
}
func main() {
c := make(chan bool)
app := &app{
// Create our initial state for the form
loginForm = &loginForm{}
}
env, err := lander.RenderInto(
// Render the form's render method instead of a function component
lander.Component(app.render, nodes.Props{}, nodes.Children{}), "#app")
if err != nil {
fmt.Println(err)
}
form.env = env
form.loginForm.env = env
<-c
}
Every component function takes a GO-lander context as its first parameter. This context is similar in concept to Golang's own context and React's context. This context carries over data that can be accessed anywhere in the tree, it allows you to define global data that all components in the tree can consume without needing to pass it as props throughout the entire tree.
As an example, consider a deeply nested series of components that you want to theme using a central theme. If you wanted to carry over the theme to all components, you would need to pass it as props to all components in the tree.
func FirstComponent(_ context.Context, _ nodes.Props, _ nodes.Children) nodes.Child {
someTheme := createTheme()
return lander.Component(secondComponent, SecondComponentProps{
theme: someTheme
}, nodes.Children{})
}
type SecondComponentProps struct {
}
func SecondComponent(_ context.Context, props SecondComponentProps, _ nodes.Children) nodes.Child {
someTheme := props.theme
return lander.Component(thirdComponent, thirdComponentProps{
theme: someTheme
}, nodes.Children{})
}
// And so on...
This example might be solved by making the theme into a global struct you can import from somewhere in your app. Another solution, depending on how you want to structure your application or library, is to store this component in the context for all to access. It works similarly to the global struct alternative, but it instead lives inside the application's render cycle. Let's rewrite the above example with context.
func FirstComponent(ctx context.Context, _ nodes.Props, _ nodes.Children) nodes.Child {
someTheme := createTheme()
if !ctx.HasValue("theme") {
ctx.SetValue("theme", theme)
}
return lander.Component(secondComponent, nodes.Props{}, nodes.Children{})
}
func SecondComponent(ctx context.Context, _ nodes.Props, _ nodes.Children) nodes.Child {
someTheme := ctx.GetValue("theme").(Theme)
return lander.Component(thirdComponent, nodes.Props{}, nodes.Children{})
}
// And so on...
The context struct provides three methods to access and set data:
HasValue(valueName string) bool
returns if there is a stored value under the given key in the context.GetValue(valueName string) interface{}
returns the value under the given key in the context, may panic if the value does not exist. We recommend checking withHasValue
to validate that the value can be found. This method returns a generic interface, you will need to type-case it to your type.SetValue(valueName string, value interface{})
sets the value under the given key in the context, you can save any type in the context. This will automatically set all components for rerender in the next update.
A component that provides a context value through SetValue
is called a provider. Once a provider is added to the
app, every other component can now see the added context value through SetValue
(see the limitations section below)
. We recommend you add all providers at the root of your application to ensure that your app always has its context
set properly.
By default, components only rerender during the diffing process if the node's change in a significant way, which could be through props changes, children changes, or the component function changing. If the component stays the same, but the context changes, then the component would not rerender with the new context value. For this reason, any change to the context will set all components as "different" and trigger a rerender.
We recommend that providers do not change the value they set in context on every render. Rather, check if the value
already exists and is already set to the value you want to be set to. This can be done with HasValue
, like in the
example below.
func ThemeProvider(ctx context.Context, _ nodes.Props, children nodes.Children) nodes.Child {
someTheme := createTheme()
if !ctx.HasValue("theme") {
// We might also want to check if the theme has changed here, for example if we provide
// a dark mode version.
ctx.SetValue("theme", theme)
}
return lander.Fragment(children)
}
Using a global theme object instead of the context in this same example would not set the components to be rerendered when the theme changes. This is the main difference between using context and a global struct.
There are a few key differences between the GO-lander context and both of its inspirations. First, GO-lander's context does not provide any cancellation or timeout mechanisms like Golang's context. We have plans to support render cancellation through the context to allow component to stop the diffing process from checking their result, but this is not yet supported. Second, GO-lander's context is a single struct that is carried over the entire tree. It is only defined once at the start of the rendering process. This differs from React's own context, as described below.
# In React, the context is redefined only for the descendants of any component that changes the content of the context
# for example:
App
| Some context provider -> Provides context value "foo"
| | Some child component with provider -> Consumes context value "foo", Provides context value "bar"
| | | Some descendant component -> Consumes context value "bar"
| | Some other child component -> Consumes context value "foo"
When a context provider redefines the context, only the descendants of that component see the new value. Other components in the tree will see the previous value in the context.
GO-lander's context is global to the tree and is a pointer, thus any change to a context value in the tree will affect all components, regardless of their location. The tree is visited in order, so this behavior is predictable. Taking the same example as above, but rewriting it in GO-lander, the context would behave as described below.
# In GO-lander, the context is a pointer that always points to the same value regardless of positions
App
| Some context provider -> Provides context value "foo"
| | Some child component with provider -> Consumes context value "foo", Provides context value "bar"
| | | Some descendant component -> Consumes context value "bar"
| | Some other child component -> Consumes context value "bar" _different from React_
This limitation is important to keep in mind when you define data in the context. As soon as SetValue
is called,
the data is available globally to all components in the tree, even components that have already been rendered.
The context object also provides a set of three lifecycle listeners, which can be used to take actions when specific
things happen to your components. All three listener types take a func() error
as their only parameter. This
function will be executed when the listening even happens. Return an error only if something critical should happen,
this will cause the entire app to stop.
ctx.OnMount
listens for the first time the component has been mounted, I.E. added to the DOM tree. This will only happens once for components and is called after the component has been mounted. By this point, the DOM nodes of its child have been added to the tree and can be accessed. Components are reused in the tree, which may lead to different mounts that you would expect. See example below.ctx.OnRender
listens for any full render of the component. This event happens whenever the component has changed in a meaningful way, which triggers a diff. This event happens after the render has happened, meaning that any HTML nodes it returns have been updated in the DOM. This will also fire on first mount, but will not fire on unmount.ctx.OnUnmount
listens for an unmount event on the component, I.E. when it is removed from the DOM tree. This event triggers only once after the component has been removed and unmount. By this point, any child it returned have been removed the DOM tree. Components are reused in the tree, which may lead to different unmounts that you would expect. See example below.
Lifecycle listeners should be called directly in the render function, they will trigger based on the chosen event.
func app(ctx context.Context, _ nodes.Props, _ nodes.Children) nodes.Child {
ctx.OnMount(func () error {
// do something on mount
})
ctx.OnRender(func () error {
// do something on render
})
ctx.OnUnmount(func () error {
// do something on unmount
})
return ...
}
As mentioned above, components are reused. For example, if you return a list of components from a tree and remove an element in the middle of that list, the unmounted component will be the last element of that list, not the one that was "removed" from the developer or user perspective. For example, consider this tree:
App
| Todo 1
| Todo 2
| Todo 3
If you remove Todo 2
, Todo 2
will be reused and updated to the values of Todo 3
, and Todo 3
will trigger an
unmount. This is different from other libraries that use a "keyed" approach to lists. GO-lander does not use keys
and instead rely on node reuse. Let's visualize this:
App
| Todo 1
| Todo 2 <- Will now use the component function, props, and children of Todo 3
| Todo 3 <- Will unmount
# After the render cycle
App
| Todo 1
| Todo 3
We have built a few experimental features that bridge the gap between other, more feature-rich, libraries and the minimalist approach of GO-lander. They are very experimental (even more than the library itself), and may be subject to change or may break in unexpected ways.
React hooks have changed the way we build frontend apps, and like many other frontend trying to compete in the landscape dominated by React, we have also built an alternative to hooks inside GO-lander. This experiment still follows the core principles of GO-lander, but given the nature of the hooks api, it required some hidden magic to properly work.
We have implemented two of the common hooks in React, useState
and useEffect
. Both use an internal version of
the useMemo
hook, which is currently not available publicly.
To use the hooks API, you must wrap your entire application inside the hooks.Provider
component. While this is
not strictly required logic wise, it does help encapsulate the logic. Future versions may remove this provider.
To see the hooks in action, let's look at the API fetch with hooks example.
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/minivera/go-lander"
"github.com/minivera/go-lander/context"
"github.com/minivera/go-lander/experimental/hooks"
"github.com/minivera/go-lander/nodes"
)
type todo struct {
Id int `json:"id"`
Todo string `json:"todo"`
Completed bool `json:"completed"`
UserId int `json:"userId"`
}
func fetchApp(ctx context.Context, _ nodes.Props, _ nodes.Children) nodes.Child {
loading, setLoading, _ := hooks.UseState[bool](ctx, true)
currentTodo, setTodo, _ := hooks.UseState[*todo](ctx, nil)
hooks.UseEffect(ctx, func() (func() error, error) {
// Simulate some loading
time.Sleep(2 * time.Second)
resp, err := http.Get("https://dummyjson.com/todos/1")
if err != nil {
return nil, err
}
loadedTodo := &todo{}
err = json.NewDecoder(resp.Body).Decode(loadedTodo)
if err != nil {
return nil, err
}
err = setTodo(func(_ *todo) *todo {
return loadedTodo
})
if err != nil {
return nil, err
}
return nil, setLoading(func(_ bool) bool {
return false
})
}, []interface{}{})
content := lander.Html("marquee", nodes.Attributes{}, nodes.Children{
lander.Text("Loading..."),
}).Style("width: 150px;")
if !loading {
content = lander.Html("div", nodes.Attributes{}, nodes.Children{
lander.Html("label", nodes.Attributes{
"for": "id",
}, nodes.Children{
lander.Text("ID"),
}),
lander.Html("input", nodes.Attributes{
"name": "id",
"value": currentTodo.Id,
"readonly": true,
}, nodes.Children{}),
lander.Html("label", nodes.Attributes{
"for": "todo",
}, nodes.Children{
lander.Text("Todo"),
}),
lander.Html("input", nodes.Attributes{
"name": "todo",
"value": currentTodo.Todo,
"readonly": true,
}, nodes.Children{}),
lander.Html("label", nodes.Attributes{
"for": "completed",
}, nodes.Children{
lander.Text("Completed?"),
}),
lander.Html("input", nodes.Attributes{
"name": "completed",
"type": "checkbox",
"checked": currentTodo.Completed,
"readonly": true,
}, nodes.Children{}),
}).Style("width: 150px;")
}
return lander.Html("div", nodes.Attributes{}, nodes.Children{
lander.Html("h1", nodes.Attributes{}, nodes.Children{
lander.Text("Sample loading app"),
}),
lander.Html("div", nodes.Attributes{}, nodes.Children{
content,
}),
}).Style("padding: 1rem;")
}
func main() {
c := make(chan bool)
_, err := lander.RenderInto(
lander.Component(hooks.Provider, nodes.Props{}, nodes.Children{
lander.Component(fetchApp, nodes.Props{}, nodes.Children{}),
}),
"#app",
)
if err != nil {
fmt.Println(err)
}
<-c
}
The hooks.UseState
hook uses a generic type to do the typecasting for you, the default value passed as its second
parameter must match the generic type. The hook always returns three variables, always typed to the generic type you
provided.
- The first parameter is the current value of the state, which will be the default value on first render. Due to how scoping works in Go, you should not use this value in event listeners or code that is not executed in the same function as the hook.
- The second parameter is the state setter function, which takes a function as its argument when called. You cannot
set the state value by passing it to the state setter, it must be a return value of a function passed to
setState
. This is to ensure that you always have the latest value of the state if you need to update it. Setting the state also automatically triggers a rerender. - The third parameter is a state getter function. Since scoping in Go means that anonymous functions may have a different version of the state value depending on when they're called, this function ensures you will always get the most up-to-date value if you need it. This is not needed if your state value is a pointer.
The hooks.UseEffect
hook takes a function as its second parameter, which in turn must return an error and a
cleanup function. The effect function given is called on mount, and on any subsequent render if and only if the
dependencies slice given as its third parameter change. If you do not want the hook to rerender, pass an empty or nil
slice.
The effect can return nil
, or another function as its cleanup. This cleanup is automatically called on unmount,
which allows you to clean any asynchronous code before the component gets unmounted.
All hooks must be given the context object of the function calling them as its first parameter. All memoized values are saved in the context, meaning that components in an application using hooks will always rerender and cannot be optimized. This should have no effect on your app's performance, but it worth considering when looking at this experimental feature.
The alternative to using the global context for state management across an entire app is to use a global struct or store of some kind. To make that experience easier on developers, we've built a very basic version of a global state store.
To use the store, create a package in your application and export a newly created store containing your state, for example:
package state
import "github.com/minivera/go-lander/experimental/state"
type appState struct {
// Some state
}
var Store = state.NewStore[appState](appState{
// State default values
})
That store exports two methods, which can be used to set or consume state. Store.SetState
sets the entire
stored state to the new value. It expects the context as its first parameter, and a setter function as its second,
which should have this signature; func(value T) T
(where T
is the generic type given to the store). This setter
will provide the current value of the store and expects a new value. We strongly recommend creating an entirely new
value when setting the state. The app will automatically update once the state has been set.
Store.Consumer
is a component you can use in your app to inject your state into another component. It takes a
Render
prop, which should provides the current value of the store and must return a valid GO-lander child, like
any other component.
lander.Component(store.Consumer, store.ConsumerProps{
Render: func (currentState appState) nodes.Child {
// Return some nodes based on the state
},
}, nodes.Children{}),
The consumer component takes care of any rerendering it needs to process. At the moment, it will always rerender even if the state has not changed between updates, which differs from more stable state management libraries.
Since WASM applications are not easily made aware of the current URL in the browser, or can easily access the
history
API to modify it, we have built this experimental set of components and utilities to help you create a
single-page application. Please note that this only supports client-side routing, you will need to handle serving
your application under any route. The examples provided in this repository do not handle routing to any other URL
than /
.
This experiment offers a Regex based, in-memory, router. This means that routes are defined as regular expressions,
including parameters. We do not provide any utilities to convert more traditional paths (like /users/:username
) to
regular expressions, at the moment.
To get started, create a package in your application and export a newly created router. This router should be available to your entire application, as it provides all the components needed to properly handle routing.
package routing
import "github.com/minivera/go-lander/experimental/router"
var Router = router.NewRouter()
Next, wrap your entire application inside a Router.Provider
component. Routing uses the context to store the
current location information and to listen to any changes in the URL. The router will only update the context when
the location changes and will not impact your app's performance by setting the entire app to rerender on every update.
package main
import (
"fmt"
"github.com/minivera/go-lander"
"github.com/minivera/go-lander/experimental/router"
"github.com/minivera/go-lander/nodes"
)
func main() {
c := make(chan bool)
_, err := lander.RenderInto(
lander.Component(appRouter.Provider, nodes.Props{}, nodes.Children{
lander.Component(yourApp, nodes.Props{}, nodes.Children{}),
}), "#app")
if err != nil {
fmt.Println(err)
}
<-c
}
Your application is now URL-aware and can use in-app routing. We provide three ways of changing the current location of your application.
Router.Navigate(to string, replace bool)
will immediately navigate the user to the new location defined into
. Ifreplace
is set totrue
, the new history entry will be replaced instead of pushed, and the user may not use the back button to go to the previous location.Router.Link
is a component that renders a single<a>
anchor element. Any children passed to the component will render inside the anchor. It can take two props,to
andreplace
, which behave exactly like theNavigate
parameters.Router.Redirect
is a component that immediately changes the location of the browser when it renders, which will trigger an update once the navigation is completed. It can take two props,to
andreplace
, which behave exactly like theNavigate
parameters.
The router also provides you with two components to conditionally render content based on the location.
Router.Route
is an "on/off" component which will only render its children if its Route
property matches the
current location. It uses a Render
prop, which takes a function that receives the current match when the URl matches.
Let's see it in action.
package main
import "github.com/minivera/go-lander/experimental/router"
func someApp(_ context.Context, _ nodes.Props, _ nodes.Children) nodes.Child {
return lander.Component(appRouter.Route, router.RouteProps{
Route: "/app/([a-zA-Z0-9]+)/(?P<subroute>[a-zA-Z0-9]+)",
Render: func(match router.Match) nodes.Child {
// Only render if the URL matches the route when compiled to a regex
// match.Pathname includes the actual URL location
// match.Params["0"] has the first path param, which as an unnamed capture group
// match.Params["subroute"])) has the second path param, which as named
},
}, nodes.Children{}),
}
The Router.Route
will extract the relevant capture groups from Route
, which work as path parameters for your
routes. A /users/:username
route in a more standard routing library would translate to
/users/(?P<username>[a-zA-Z0-9]+)
for example. The Render
function is called with a match struct containing the
pathname and the path parameters. Any unnamed captured groups are stored in the match.Params
map under the index
in the regex. For example, if we had /users/(?P<username>[a-zA-Z0-9]+)/([a-zA-Z0-9]+)
, the second path param would
be stored under the index "1"
.
The Router.Route
can be chained to create a complex router. However, each route is checked on render and multiple
routes may match at the same time. To make sure only one route renders, use the Router.Switch
component.
package main
import "github.com/minivera/go-lander/experimental/router"
func someApp(_ context.Context, _ nodes.Props, _ nodes.Children) nodes.Child {
lander.Component(appRouter.Switch, router.SwitchProps{
Routes: router.RouteDefinitions{
{"/$", func(_ router.Match) nodes.Child {
// Home path
}},
{"/hello/test$", func(_ router.Match) nodes.Child {
// Route with subpath
}},
{"/hello$", func(_ router.Match) nodes.Child {
// Hello route, must be after the more specific route as it might match
// the subroutes.
}},
{".*", func(match router.Match) nodes.Child {
// Catch all, 404 route.
}},
},
}, nodes.Children{}),
}
The Router.Switch
component takes a set of router.RouteDefinitions
as its single Routes
prop. These
definitions are identical to the Router.Router
props. The switch will check each route in order against the
current location and render the first match it finds. For this reason, you may want to have your more specific
routes appear before first level routes as they might match against sub-routes, as explained in the code above. The
.*
catch all regex can be added at the end to render something if no route matches.
See more in the routing example.
Managing the head of the document is a common pattern in JavaScript when in-app routing is introduced. To provide a
good routing story to developers, we have built an experiment allowing some basic manipulation of the head
tag of
the document directly from GO-lander's tree.
To get started, wrap your entire application inside a helmer.Provider
component. This provider will update the
document's head on every render. The experiment does not check if updating the head is necessary at the moment and
will always update when the tree rerenders.
package main
import (
"fmt"
"github.com/minivera/go-lander"
"github.com/minivera/go-lander/experimental/helmet"
"github.com/minivera/go-lander/nodes"
)
func main() {
c := make(chan bool)
_, err := lander.RenderInto(
lander.Component(helmet.Provider, nodes.Props{}, nodes.Children{
lander.Component(yourApp, nodes.Props{}, nodes.Children{}),
}), "#app")
if err != nil {
fmt.Println(err)
}
<-c
}
Your application can now provide new head
tags, such as title
as HTML nodes directly in the tree. To do so, use
the provided helmer.Head
component. Any HTML children passed to this component will be evaluated at render time,
if the node's tag is one of title
, meta
, link
, script
, noscript
, or style
, it will be saved internally
to be added to the head at the end of the render cycle.
package main
import "github.com/minivera/go-lander/experimental/router"
func someApp(_ context.Context, _ nodes.Props, _ nodes.Children) nodes.Child {
return lander.Component(helmet.Head, router.RouteProps{}, nodes.Children{
lander.Html("title", nodes.Attributes{}, nodes.Children{
lander.Text("Some title"),
}),
lander.Html("script", nodes.Attributes{}, nodes.Children{
lander.Text("(function() { alert('test'); })();"),
}),
lander.Html("style", nodes.Attributes{}, nodes.Children{
lander.Text("* { color: red; }"),
}),
}),
}
All tags will be rendered and updated when the tree updates like you would expect with a reactive library like
GO-lander. The title
tag is unique however as only one tag can exist in a page. Helmet
prioritizes the last tag
seen in the tree, given a walk from the "top" of your app's tree to the "bottom". For example:
# Titles are in order of priority, if 1 is removed, 2 will be selected and so on.
SomeApp
| Head with title = "4"
| div
| | Head with title = "3"
| div
| | div
| | | Head with title = "2"
| Head with title = "1" <- This will be the final title
See more in the helmet example.
Lander would not have been possible without the massive work done by the contributors of these libraries: