Build a Desktop App with Wails #3: Connecting Go and the Frontend — Method Bindings and Events

5 min read

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 methodOn the JavaScript side
func (a *App) Do()Do() — Promise<void>
func (a *App) Do() stringDo() — 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 lowercaseNot 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.

app.go — the todo-list backend
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.

frontend/src/App.tsx — the core part
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.

app.go — reporting progress through events
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),
			})
		}
	}()
}
React — receiving the event
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.

Note
Events are a two-way channel: the frontend can also emit to Go (EventsEmit), and frontend components can signal each other. That said, request-response pairs are clearer as binding calls, so keeping events for “notifications pushed without a request” keeps the architecture simple.

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.

X