DEV Community

Cover image for Built Gova, a declarative GUI framework for Go
Naman vyas
Naman vyas

Posted on

Built Gova, a declarative GUI framework for Go

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{}
    }))
}
Enter fullscreen mode Exit fullscreen mode

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, NSDockTile badge, progress, and menu. Fyne fallbacks on Windows and Linux so portable code keeps running.
  • gova dev CLI with hot reload. UI state optionally survives the reload via PersistedState.
  • One codebase, three targets. 32 MB static binary for Counter, 23 MB stripped.
  • Headless testing via TestRender so 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),
        )
    },
)
Enter fullscreen mode Exit fullscreen mode

Modifier order does not matter. Defaults are Go zero values. The compiler checks your UI.

Install

go get github.com/nv404/gova@latest
Enter fullscreen mode Exit fullscreen mode

Optional CLI for a hot-reload workflow:

go install github.com/nv404/gova/cmd/gova@latest
gova dev ./examples/counter
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
peacebinflow profile image
PEACEBINFLOW

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) calls State(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?