Guides
Data
Values kept across launches, SQLite with live queries, and secure storage.
An app keeps its data in a few places: small values in ryui/store, records
in SQLite with ryui/sqlite, secrets in the platform's keychain with
ryui/secure, and files of its own with Go's os. This page covers each,
the network through net/http, and which calls may block the main thread.
Where data lives
store.Dir() is the app's data directory: on iOS, Application Support in
the app's container; on Android, the app's files directory. ryui/store's
file is there, and so are the databases sqlite.Open makes from a plain
name. Put your own files there too.
Under ryui dev, your Go code runs on your Mac, so store.Dir() is a
directory on the Mac (in the user's config directory, under ryui/), and
the store, the databases and your files are there. ryui/secure is the
exception: it is the device's keychain, as in a release build.
Small values: ryui/store
ryui/store keeps small values across launches: settings, a signed-in
user's name, the tab last open. Values are JSON, in one file
(store.json in store.Dir()), written off the main thread a moment
after they change, and when the app goes to the background. It's not a
database: for many or large records, use SQLite.
store.Signal is a signal whose value is kept. It starts with the value
kept, or the default the first time, and each value it's set to is kept:
func Settings() ui.Node {
dark := store.Signal("dark", false) // false the first time; then what it was last set to
return ui.Toggle(ui.ToggleProps{Value: dark})
}Make it on the main thread, as any signal. A package-level variable works
too (examples/downloader has var wifiOnly = store.Signal("wifiOnly", false)). Each call makes a new signal: two made with the same key don't
follow each other, so make one and share it.
Get, Set and Delete work on values directly. Set takes anything
encoding/json encodes; Get decodes into a pointer, and returns false
if the key has no value, or the value doesn't decode into it:
type Prefs struct {
Theme string
FontSize int
}
store.Set("prefs", Prefs{Theme: "dark", FontSize: 17})
var p Prefs
if !store.Get("prefs", &p) {
p = Prefs{Theme: "light", FontSize: 15}
}
store.Delete("prefs")store.Flush() waits until what's pending is written. RyUI calls it when
the app goes to the background; you need it only in tests.
Records: ryui/sqlite
ryui/sqlite is an app's SQLite database. SQLite is
modernc.org/sqlite, a translation
of SQLite's C to Go: it needs no cgo, so the same code runs on iOS,
Android and, under ryui dev, the Mac. examples/notes
uses it.
Open and migrate
var db *sqlite.DB
func main() {
var err error
db, err = sqlite.Open("notes.db")
if err != nil {
panic(err)
}
err = db.Migrate(
// 1
`create table notes (id integer primary key, text text not null, done bool not null default 0)`,
// 2
`alter table notes add column created_at integer not null default 0;
create index notes_done on notes (done)`,
)
if err != nil {
panic(err)
}
app.Run(App)
}Open takes a name, a file in store.Dir() (the directory is made if it
isn't there), an absolute path, or ":memory:". It turns on write-ahead
logging and foreign keys, and sets a busy timeout of 5 seconds.
Migrate brings the schema up to date. Each argument is a version of the
database, in order: its SQL (statements separated by semicolons) runs
once, in a transaction, and the version is recorded in PRAGMA user_version. Add a step to change the schema; never change a step that
has shipped, since phones that ran it won't run it again.
db.Close() closes it. Most apps open one database at launch and keep
it.
Query
sqlite.Query[T] runs a query and scans its rows into a slice of T.
For a struct, each column goes to the field of the same name: the field's
db tag, or its name, case-insensitively, with the column's underscores
ignored (created_at fills CreatedAt). Columns without a field are
skipped; a field tagged db:"-" is never filled. For any other T, the
first column is scanned.
type Note struct {
ID int64
Text string
Done bool
Created int64 `db:"created_at"`
Draft string `db:"-"`
}
notes, err := sqlite.Query[Note](db, `select * from notes where done = ? order by id`, false)
n, ok, err := sqlite.QueryOne[Note](db, `select * from notes where id = ?`, 1)
count, _, err := sqlite.QueryOne[int](db, `select count(*) from notes`)QueryOne is the first row; ok is false if there's none.
Exec and transactions
db.Exec runs a statement (insert, update, delete, create...) and returns
its sql.Result. db.Tx runs a function in a transaction: committed if
it returns nil, rolled back if it returns an error (which Tx returns) or
panics. In it, tx.Exec writes and sqlite.Query and sqlite.QueryOne
read, given the *sqlite.Tx instead of the database:
err := db.Tx(func(tx *sqlite.Tx) error {
if _, err := tx.Exec(`update notes set done = 1 where id = ?`, 1); err != nil {
return err // rolled back
}
left, _, err := sqlite.QueryOne[int](tx, `select count(*) from notes where done = 0`)
if err != nil {
return err
}
fmt.Println(left, "left")
return nil // committed
})Writes off the main thread
Exec, Tx, Query, QueryOne and Migrate wait for the disk. On the
main thread, write with ExecAsync and TxAsync: they run off it, then
call done on the main thread. With a nil done, an error is logged.
db.ExecAsync(`insert into notes (text, created_at) values (?, ?)`, []any{"Buy milk", time.Now().Unix()},
func(r sql.Result, err error) {
if err != nil {
status.Set("Couldn't save: " + err.Error())
return
}
id, _ := r.LastInsertId()
status.Set(fmt.Sprintf("Saved note %d", id))
})
db.TxAsync(func(tx *sqlite.Tx) error {
if _, err := tx.Exec(`delete from notes where done = 1`); err != nil {
return err
}
_, err := tx.Exec(`update notes set text = trim(text)`)
return err
}, nil)To read from the main thread, use a live query (below), or a goroutine
that queries, then ui.Posts the rows back.
Live queries
sqlite.Watch[T] is a live query: a signal of its rows, fetched off the
main thread now, and again each time a write to a table it reads commits.
Make it in a component; it stops with the component's owner.
func Notes() ui.Node {
notes := sqlite.Watch[Note](db, `select * from notes order by id desc`)
return ui.Text(signal.Func(func() string {
r := notes.Get()
switch {
case r.Loading:
return "Loading…"
case r.Err != nil:
return r.Err.Error()
}
return fmt.Sprintf("%d notes", len(r.Rows))
}))
}Its value is a sqlite.Result[T]: Rows, the latest rows; Err, the
error the query last got; and Loading, true until the first fetch is
done. Give Rows to a ui.List and the list follows the table, as in
examples/notes.
A live query reruns only for the tables it reads. It learns them as SQLite prepares it, the tables behind a view included, and reruns when a write to one of them commits, including rows written by triggers and by foreign keys' actions. A write to another table doesn't run it. Writes that come while it's fetching make one more fetch, not one each.
sqlite.WatchWith[T] is Watch with arguments that change. Its
arguments come from a function that reads signals (a search field's text,
a page size), and the query runs again when they change, as when the
database does. Until the new rows come, the old ones stay; rows fetched
for arguments that have since changed are dropped.
func Search() ui.Node {
q := signal.New("")
notes := sqlite.WatchWith[Note](db, `select * from notes where text like ? order by id`,
func() []any { return []any{"%" + q.Get() + "%"} })
return ui.Column(nil,
ui.TextField(ui.TextFieldProps{Value: q, Placeholder: ui.Val("Search")}),
ui.Text(signal.Func(func() string { return fmt.Sprintf("%d found", len(notes.Get().Rows)) })),
)
}Beyond this package
db.SQL() is the *sql.DB underneath, for what ryui/sqlite doesn't
cover. Writes made through it are noted too, but the live queries hear of
them at the next Exec or Tx, or at db.Changed().
db.Changed() runs every live query again. Call it after a change the
database can't see: another process's, or the file replaced (a backup
restored).
Secrets: ryui/secure
ryui/secure keeps small secrets, tokens and passwords, encrypted by the
platform. It's for secrets, not data: keep everything else in the store
or a database.
- iOS: the Keychain, as generic passwords under the app's bundle ID, readable after the device's first unlock, and on this device only.
- Android: each value is encrypted (AES-GCM) with a key in the Android Keystore, which never leaves it, and kept in the app's private preferences.
On the main thread, Load, Save and Remove take a callback, called
on the main thread:
func Account() ui.Node {
status := signal.New("")
secure.Load("token", func(token string, ok bool, err error) {
switch {
case err != nil:
status.Set(err.Error())
case !ok:
status.Set("Signed out")
default:
status.Set("Signed in")
}
})
return ui.Column(nil,
ui.Text(status),
ui.Button(ui.ButtonProps{Title: ui.Val("Sign out"), OnPress: func() {
secure.Remove("token", func(err error) { status.Set("Signed out") })
}}),
)
}
func signIn(token string) {
secure.Save("token", token, nil) // nil: no callback
}From a goroutine, Get, Set and Delete wait for the platform and
return. Never call them on the main thread:
go func() {
token, ok, err := secure.Get("token")
if err != nil || !ok {
return
}
// ... use token ...
}()Files and the network
Files are Go's os, and the network is Go's net/http: RyUI adds nothing
of its own. Keep the files in store.Dir(). Files RyUI's packages give you
(a photo taken, a video recorded, photos picked) are in the app's caches
or temporary directory, which the system may clear: move one into
store.Dir() to keep it.
Both wait, so do them in a goroutine, and ui.Post the result back to
the main thread:
func Avatar() ui.Node {
path := signal.New("")
status := signal.New("Downloading…")
go func() {
file := filepath.Join(store.Dir(), "avatar.jpg")
err := download("https://example.com/avatar.jpg", file)
ui.Post(func() {
if err != nil {
status.Set(err.Error())
return
}
status.Set("")
path.Set(file)
})
}()
return ui.Column(nil,
ui.Text(status),
ui.Image(ui.ImageProps{Source: path}),
)
}
func download(url, file string) error {
res, err := http.Get(url)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%s", res.Status)
}
if err := os.MkdirAll(filepath.Dir(file), 0o700); err != nil {
return err
}
out, err := os.Create(file)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, res.Body)
return err
}To keep a long download going while the app is out of sight, see
ryui/background; to know
whether the connection is Wi-Fi or metered, see
ryui/network.
examples/downloader does both.
The main thread
The interface runs on the main thread, and a call that waits there freezes it. The main thread explains the rule; for data, it comes to this:
- Don't call on the main thread:
sqlite.Open,Migrate,Exec,Tx,QueryandQueryOne(except at launch, beforeapp.Run);secure.Get,SetandDelete; file and network calls. - Call on the main thread:
ExecAsync,TxAsync,Watch,WatchWith;secure.Load,SaveandRemove;store.Signal, as any signal. - Either:
store.Get,SetandDelete, which only read and write memory (the file is written off the main thread).
From a goroutine, ui.Post(f) runs f on the main thread: set signals
there.