I spent the last few months building Gova. It is a declarative GUI framework for Go that compiles native desktop apps to a single static binary on macOS, Windows, and Linux. Struct-based components, reactive state, real native dialogs where the platform offers them. No Electron, no embedded webview, no JavaScript runtime.
Here is a full Counter app:
package main
import g "github.com/nv404/gova"
type Counter struct{}
func (Counter) Body(s *g.Scope) g.View {
count := g.State(s, 0)
return g.VStack(
g.Text(count.Format("Count: %d")).Font(g.Title),
g.HStack(
g.Button("-", func() { count.Set(count.Get() - 1) }),
g.Button("+", func() { count.Set(count.Get() + 1) }),
).Spacing(g.SpaceMD),
).Padding(g.SpaceLG)
}
func main() {
g.Run("Counter", g.Define(func(s *g.Scope) g.View {
return Counter{}
}))
}
go run . and a real native window appears. No scaffolding, no config file, no build step beyond the Go toolchain.
What ships today
- Components as plain Go structs with typed prop fields, composed with function calls.
- Reactive primitives:
State,Signal,Store,PersistedState. All keyed by call site, which means no hook rules and no string keys. - Real native dialogs on macOS through cgo:
NSAlert,NSOpenPanel,NSSavePanel,NSDockTilebadge, progress, and menu. Fyne fallbacks on Windows and Linux so portable code keeps running. -
gova devCLI with hot reload. UI state optionally survives the reload viaPersistedState. - One codebase, three targets. 32 MB static binary for Counter, 23 MB stripped.
- Headless testing via
TestRenderso you can assert on the view tree without opening a window.
A slightly bigger slice
A todo row with a reactive model, a delete button, and a text field that grows to fill the width:
gova.List(todos,
func(t Todo) int { return t.ID },
func(i int, todo Todo) gova.View {
return gova.HStack(
gova.Toggle(todo.Done).OnChange(func(done bool) {
model.Update(func(m Model) Model {
return toggleTodo(m, todo.ID, done)
})
}),
gova.Text(todo.Text),
gova.Spacer(),
gova.Button("Delete", func() {
model.Update(func(m Model) Model {
return removeTodo(m, todo.ID)
})
}).Color(gova.Red),
)
},
)
Modifier order does not matter. Defaults are Go zero values. The compiler checks your UI.
Install
go get github.com/nv404/gova@latest
Optional CLI for a hot-reload workflow:
go install github.com/nv404/gova/cmd/gova@latest
gova dev ./examples/counter
Requires Go 1.26+ and a C toolchain. One go get pulls Fyne and its native dependencies transitively.
Why another Go GUI framework
Go already has Fyne, Wails, and Gio. I used all three and wanted a different mental model: struct-based components with typed props, reactive state that survives refactors, and real platform widgets where the platform cares about them. Gova sits on top of Fyne for rendering so I did not have to rewrite a toolkit, but the public surface is its own.
Where to look next
- Repo: github.com/NV404/gova
- Docs and examples: gova.dev
- Runnable examples in the repo:
counter,todo,fancytodo,notes,themed,components,dialogs
It is pre-1.0. The API will move before v1.0.0, and I would rather hear honest critique than polite silence.
If the project looks useful, a star on the repo helps it surface to other Go devs looking in the desktop space.
Top comments (1)
The thing that catches my attention here isn't the framework itself—it's the decision to key reactive state by call site rather than by string keys or hook ordering rules. That's one of those design choices that seems small but cascades through the entire developer experience.
React's hook rules exist because the runtime needs to match state to calls positionally. String-keyed stores break when you rename things. Call site keying basically says "the compiler can see where this State() call lives, so let the compiler be the key." In Go specifically, that feels natural—the language already leans on the compiler for guarantees that JavaScript frameworks have to enforce at runtime with linters.
I'm curious how this holds up when components get extracted into helper functions that might be called from multiple places. If
func labeledCounter(label string)callsState(s, 0)internally, does each call site get its own state, or does the single definition mean they share? Both are reasonable answers, but the implications for refactoring are pretty different.The single-binary story is what'll get people to try it, but the call-site-keyed reactivity is what'll determine whether they stay. What surprised you most about that approach once you started using it in earnest?