Build a Desktop App with Wails #3: Connecting Go and the Frontend — Method Bindings and Events
In the last post we saw how the methods in app.go are exposed as JavaScript functions in wailsjs. This post puts that connection to real use. We pin down the binding rules precisely, build a todo-list backend and connect it to a React frontend, and then cover communication in the opposite direction: events. Examples use the react-ts template.
It runs in eight parts (six main plus two deep-dives).
- #1 What Wails is — lightweight desktop apps in Go
- #2 Project structure and the dev loop — wails dev and bindings
- #3 Connecting Go and the frontend — method bindings and events ← this post
- #4 System integration — dialogs, menus, and window control
- #5 Real-world features — persisting settings and error handling
- #6 Build and distribution — packaging per platform
Binding rules — what gets exposed, and how #
Of the structs registered in Bind, only public methods (starting with an uppercase letter) are exposed to the frontend. Here is how Go signatures map to the JavaScript side.
| Go method | On the JavaScript side |
|---|---|
func (a *App) Do() | Do() — Promise<void> |
func (a *App) Do() string | Do() — resolves as Promise<string> |
func (a *App) Do() (string, error) | resolves when error is nil, rejects otherwise |
func (a *App) Do(n int, s string) | Do(n, s) — arguments in the same order |
| Method starting with lowercase | Not exposed |
The key is the error mapping. Follow the Go convention of returning error as the last value, and on the frontend it arrives as a Promise rejection you can handle with try/catch. The error-handling conventions of both languages connect naturally by design.
Hands-on — a todo-list backend #
Let’s build a backend that keeps todos in memory. Add a type and methods to app.go.
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
type App struct {
ctx context.Context
todos []Todo
nextID int
mu sync.Mutex
}
func (a *App) AddTodo(title string) (Todo, error) {
if strings.TrimSpace(title) == "" {
return Todo{}, errors.New("title is empty")
}
a.mu.Lock()
defer a.mu.Unlock()
a.nextID++
todo := Todo{ID: a.nextID, Title: title}
a.todos = append(a.todos, todo)
return todo, nil
}
func (a *App) ListTodos() []Todo {
a.mu.Lock()
defer a.mu.Unlock()
return a.todos
}
func (a *App) DeleteTodo(id int) {
a.mu.Lock()
defer a.mu.Unlock()
a.todos = slices.DeleteFunc(a.todos, func(t Todo) bool {
return t.ID == id
})
}This is ordinary Go code. Nothing about it is special to desktop apps — the only addition is a mutex, since the methods may be reached from goroutines. Storage is in memory, so everything disappears when the app closes; persisting to a file is solved in #5.
Calling from the frontend #
Run wails dev and three functions appear in wailsjs. Use them directly from a React component.
import { useEffect, useState } from 'react';
import { AddTodo, ListTodos, DeleteTodo } from '../wailsjs/go/main/App';
import { main } from '../wailsjs/go/models';
function App() {
const [todos, setTodos] = useState<main.Todo[]>([]);
const [title, setTitle] = useState('');
useEffect(() => {
ListTodos().then(setTodos);
}, []);
async function handleAdd() {
try {
await AddTodo(title);
setTitle('');
setTodos(await ListTodos());
} catch (err) {
alert(err); // the error message returned by Go
}
}
// ...render the input and the list
}Call AddTodo with an empty title and the Go side returns an error, which the frontend catches. A backend and a frontend joined by function calls inside one process, no server involved — that is what developing with Wails feels like.
Structs and TypeScript models #
The main.Todo type imported in the code above deserves attention. Wails analyzes the structs that bound methods send and receive, and generates TypeScript models automatically (wailsjs/go/models.ts). The json tags on the Go struct decide the field names, so with tags in place the frontend works with lowercase fields naturally. Change a Go type and the model is regenerated on the next build, so mismatches between the two sides are caught at compile time.
Events — pushing from Go to the frontend #
Binding calls are always initiated by the frontend. But sometimes Go needs to speak first — progress of a long-running job is the classic case. That is what the event system is for.
import "github.com/wailsapp/wails/v2/pkg/runtime"
func (a *App) ProcessFiles(paths []string) {
go func() {
for i, path := range paths {
process(path)
runtime.EventsEmit(a.ctx, "progress", map[string]any{
"done": i + 1,
"total": len(paths),
})
}
}()
}import { EventsOn } from '../wailsjs/runtime/runtime';
useEffect(() => {
const off = EventsOn('progress', (data) => {
setProgress(data);
});
return off; // unsubscribe when the component is cleaned up
}, []);The a.ctx passed as the first argument to EventsEmit is the startup context from #2. The Wails runtime functions connect to the app through this context, so the convention of storing the context in startup keeps coming back. The structure above — a bound method that fires EventsEmit from inside a goroutine — is the basic pattern for keeping the UI responsive during heavy work.
Wrapping up #
This post comes down to three points.
- Bindings expose only public methods; return values map to Promise resolution and errors to rejection.
- Structs get auto-generated TypeScript models based on their json tags, keeping the types on both sides in sync.
- Information that Go must announce first travels through EventsEmit and EventsOn, and heavy work runs in a goroutine that reports progress through events.
In the next post, “Build a Desktop App with Wails #4: System Integration — Dialogs, Menus, and Window Control”, we add the features that make it feel like a real desktop app. File-open dialogs, native menus, and window control — the things a browser could never do — handled through the Wails runtime.