Get started
Example: Popular GitHub repositories
List the most-starred Go repositories from GitHub's API, loading on a goroutine with a spinner, errors and a retry.
This tutorial builds an app that lists the most-starred Go repositories on GitHub. It covers what every app that talks to a server needs: a request that does not block the screen, a loading indicator, an error with a retry, and a test that runs without the network. It assumes the setup.
Create the app
mkdir repos && cd repos
go mod init example.com/repos
go get github.com/nsgo-dev/nsgo
go get -tool github.com/nsgo-dev/nsgo/cmd/nsgo
go tool nsgo init
go tool nsgo dev
Write the app
Replace the whole of app.go:
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/nsgo-dev/nsgo"
"github.com/nsgo-dev/nsgo/core"
"github.com/nsgo-dev/nsgo/ui"
)
// api lists the most-starred Go repositories on GitHub.
var api = "https://api.github.com/search/repositories?q=language:go&sort=stars&per_page=30"
type repo struct {
Name string `json:"full_name"`
Description string `json:"description"`
Stars int `json:"stargazers_count"`
}
// fetch runs on its own goroutine, never the engine's. ctx is cancelled
// once its answer no longer counts.
func fetch(ctx context.Context) ([]repo, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, api, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub answered %s", resp.Status)
}
var body struct {
Items []repo `json:"items"`
}
err = json.NewDecoder(resp.Body).Decode(&body)
return body.Items, err
}
func main() { nsgo.Run(Root) }
func Root(cx *core.Scope) core.Node {
repos := core.NewResource(cx, nil, func(ctx context.Context, _ struct{}, _ core.ResourceInfo) ([]repo, error) {
return fetch(ctx)
})
list := func() []repo {
list, _ := repos.Get()
return list
}
failure := func() string {
if err := repos.Err(); err != nil {
return err.Error()
}
return ""
}
colors := ui.ColorsOf(cx)
return core.View{
Style: core.Style{FlexGrow: 1, FlexShrink: 1, PaddingEnv: core.EnvAll(core.EnvSafeArea)},
StyleFunc: func() core.Style { return core.Style{Background: colors.Background()} },
Children: []core.Node{
core.View{Style: core.Style{Direction: core.Row, AlignItems: core.AlignCenter, Gap: 12, Padding: core.All(16)}, Children: []core.Node{
ui.Text("Popular Go repositories", 22, ui.Bold, colors.Text),
core.Show{When: repos.Loading, Then: func(*core.Scope) core.Node { return ui.Spinner{Label: "Loading"} }},
}},
core.Show{
When: func() bool { return repos.State() == core.ResourceErrored },
Then: func(*core.Scope) core.Node {
return core.View{Style: core.Style{Padding: core.All(16), Gap: 12, AlignItems: core.AlignStart}, Children: []core.Node{
ui.Textf(failure, 14, ui.Regular, colors.Muted),
ui.Button{Text: "Try again", OnPress: repos.Refetch},
}}
},
Else: func(*core.Scope) core.Node {
return core.List{
Count: func() int { return len(list()) },
Key: func(i int) any { return list()[i].Name },
EstimatedSize: 64,
Style: core.Style{FlexGrow: 1},
Row: func(cx *core.Scope, row core.ListRow) core.Node {
r := list()[row.Index()]
return core.View{Style: core.Style{Padding: core.Symmetric(10, 16), Gap: 2}, Children: []core.Node{
core.View{Style: core.Style{Direction: core.Row, JustifyContent: core.JustifySpaceBetween, Gap: 8}, Children: []core.Node{
ui.Text(r.Name, 15, ui.Medium, colors.Text),
ui.Text(fmt.Sprintf("★ %d", r.Stars), 13, ui.Regular, colors.Muted),
}},
ui.Text(r.Description, 13, ui.Regular, colors.Muted),
}}
},
}
},
},
},
}
}
The spinner shows while the request runs, then the list fills in.
How it works
Go’s own networking. net/http works on every target, with the system’s TLS and proxy settings, so fetch is plain Go.
Off the engine’s goroutine. Components and signals belong to the engine’s goroutine, which must never wait on the network. core.NewResource runs fetch on a goroutine of its own and brings the result back to the engine. Its source is nil, so it loads once, and Try again calls Refetch to load again. Only the latest load counts: leaving the screen cancels the request through ctx, and a late answer never replaces a newer one.
State as signals. repos.Get, repos.Err, repos.State and repos.Loading are signals, so bindings that read them follow each load. core.Show shows the spinner while it loads, and either the error with Try again or the list, rebuilding only that part when its condition changes. A retry keeps the last list until the new one arrives.
A list of any length. core.List builds only the rows near the screen, keyed by the repository’s name, so a new result reuses the rows of the same repositories.
Test it
The request’s URL is a variable, so a test points it at a local server with httptest and controls what the “network” answers:
package main
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/nsgo-dev/nsgo/nsgotest"
)
func TestMain(m *testing.M) { nsgotest.Main(m) }
// serve answers the app's requests with body instead of GitHub.
func serve(t *testing.T, status int, body string) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
w.Write([]byte(body))
}))
t.Cleanup(server.Close)
api = server.URL
}
func TestShowsRepositories(t *testing.T) {
serve(t, http.StatusOK, `{"items":[{"full_name":"golang/go","description":"The Go programming language","stargazers_count":130000}]}`)
app := nsgotest.Start(t, Root)
app.WaitFor("text:golang/go", 5*time.Second)
app.Find("text:★ 130000")
}
func TestRetriesAfterAFailure(t *testing.T) {
serve(t, http.StatusServiceUnavailable, "")
app := nsgotest.Start(t, Root)
app.WaitFor("text:Try again", 5*time.Second)
}
go tool nsgo test
WaitFor waits until the answer has arrived and the screen shows it.
Next
- Platform services: more on networking, storage and files.
- Lists and scrolling: pull to refresh, snapping and long lists.
- Running and debugging: watch each update with
-trace.