Desktop CRUD & Data
In this course, you will learn how CRUD operations work in desktop apps, generate resources with the Grit code generator, understand Wails bindings in depth, and work with SQLite through GORM.
How Desktop CRUD Differs
In web apps, React calls REST APIs over HTTP. You use fetch() or axios to send requests to endpoints like GET /api/blogs.
In desktop apps, React calls Go functions directly via Wails bindings. No HTTP, no endpoints, no network — just function calls:
| Operation | Web App (HTTP) | Desktop App (Wails) |
|---|---|---|
| List | fetch("/api/tasks") | GetTasks() |
| Create | fetch("/api/tasks", { method: "POST" }) | CreateTask({ title: "..." }) |
| Update | fetch("/api/tasks/1", { method: "PUT" }) | UpdateTask(1, { title: "..." }) |
| Delete | fetch("/api/tasks/1", { method: "DELETE" }) | DeleteTask(1) |
This is simpler and faster — no serialization, no HTTP overhead, no CORS configuration. Your React code just calls Go functions and gets results back.
Generating Resources
The grit generate resource command works for desktop projects too — same syntax, same workflow, but different output. For desktop, it generates Wails bindings instead of HTTP handlers, and TanStack Router routes instead of Next.js pages:
grit generate resource Task --fields "title:string,description:text,done:bool,priority:int"This creates everything you need for a full Task CRUD:
| File | Purpose |
|---|---|
| internal/models/task.go | GORM model with struct tags |
| internal/services/task_service.go | Business logic (List, Create, GetByID, Update, Delete) |
| internal/types/task_types.go | Input/output types for Wails bindings |
| app.go (injected) | New bound methods added to the App struct |
| frontend/src/routes/tasks/ | TanStack Router pages (list, create, edit) |
| frontend/src/components/sidebar.tsx | Sidebar entry injected automatically |
Challenge: Generate a Task Resource
Run grit generate resource Task --fields "title:string,description:text,done:bool,priority:int" in your desktop project. List the files that were created. Did the sidebar update with a new "Tasks" entry?
Understanding Wails Bindings
When you generate a resource, Grit adds methods to app.go that become the bridge between Go and React:
// List all tasks
func (a *App) GetTasks() ([]models.Task, error) {
return a.taskService.List()
}
// Create a new task
func (a *App) CreateTask(input types.CreateTaskInput) (*models.Task, error) {
return a.taskService.Create(input)
}
// Get a single task by ID
func (a *App) GetTask(id uint) (*models.Task, error) {
return a.taskService.GetByID(id)
}
// Update an existing task
func (a *App) UpdateTask(id uint, input types.UpdateTaskInput) (*models.Task, error) {
return a.taskService.Update(id, input)
}
// Delete a task
func (a *App) DeleteTask(id uint) error {
return a.taskService.Delete(id)
}On the React side, Wails automatically generates TypeScript wrappers. You import and call them like any other function:
import { GetTasks, CreateTask, DeleteTask } from '../../../wailsjs/go/main/App'
// Fetch all tasks
const tasks = await GetTasks()
// Create a new task
const newTask = await CreateTask({
title: "Buy groceries",
description: "Milk, eggs, bread",
done: false,
priority: 1
})
// Delete a task
await DeleteTask(newTask.ID)wailsjs/go/ directory. These functions handle serialization and deserialization automatically — you pass JavaScript objects and get JavaScript objects back.Challenge: Explore the Generated Bindings
After generating the Task resource, look in wailsjs/go/main/App.ts (this file is generated when you run wails dev). What functions are available? Do they match the Go methods in app.go?
GORM Models for Desktop
Desktop models use the same GORM patterns as web projects, but with the SQLite driver instead of PostgreSQL:
package models
import "gorm.io/gorm"
type Task struct {
gorm.Model
Title string `gorm:"not null" json:"title"`
Description string `gorm:"type:text" json:"description"`
Done bool `gorm:"default:false" json:"done"`
Priority int `gorm:"default:0" json:"priority"`
}JSONB type — use TEXT for storing JSON strings. There are no arrays — use TEXT with comma-separated values or JSON. Most standard types (string, int, bool, float, time) work identically.Challenge: Compare Models
Open the Task model at internal/models/task.go. Compare it to a web project model if you have one. What's the same? The struct tags, the GORM model embedding, and the JSON tags should all look familiar.
TanStack Router for Desktop
routes/tasks/index.tsx becomes the /tasks page. A file at routes/tasks/$id.edit.tsx becomes /tasks/123/edit.Desktop apps use TanStack Router with hash history for Wails compatibility. The route files live in frontend/src/routes/:
frontend/src/routes/
├── __root.tsx <- Root layout (sidebar + title bar)
├── index.tsx <- Dashboard (/)
├── tasks/
│ ├── index.tsx <- Task list (/tasks)
│ ├── new.tsx <- Create task (/tasks/new)
│ └── $id.edit.tsx <- Edit task (/tasks/123/edit)
├── blogs/
│ ├── index.tsx <- Blog list (/blogs)
│ ├── new.tsx <- Create blog (/blogs/new)
│ └── $id.edit.tsx <- Edit blog (/blogs/123/edit)
└── contacts/
├── index.tsx
├── new.tsx
└── $id.edit.tsxThe $id prefix means a dynamic parameter — TanStack Router extracts the value from the URL and makes it available in your component. So /tasks/42/edit gives you id = 42.
Challenge: Explore Route Files
Find the route files for your generated Task resource in frontend/src/routes/tasks/. What URL patterns do they follow? Open index.tsx — can you see where it calls GetTasks()?
The Service Layer
Services contain all business logic. The App methods in app.go call services, and services call GORM. This is the same pattern used in web projects — keeping logic separate from the transport layer:
type TaskService struct {
db *gorm.DB
}
func NewTaskService(db *gorm.DB) *TaskService {
return &TaskService{db: db}
}
func (s *TaskService) List() ([]models.Task, error) {
var tasks []models.Task
result := s.db.Order("created_at DESC").Find(&tasks)
return tasks, result.Error
}
func (s *TaskService) Create(input types.CreateTaskInput) (*models.Task, error) {
task := models.Task{
Title: input.Title,
Description: input.Description,
Done: input.Done,
Priority: input.Priority,
}
result := s.db.Create(&task)
return &task, result.Error
}
func (s *TaskService) GetByID(id uint) (*models.Task, error) {
var task models.Task
result := s.db.First(&task, id)
return &task, result.Error
}
func (s *TaskService) Update(id uint, input types.UpdateTaskInput) (*models.Task, error) {
var task models.Task
if err := s.db.First(&task, id).Error; err != nil {
return nil, err
}
result := s.db.Model(&task).Updates(input)
return &task, result.Error
}
func (s *TaskService) Delete(id uint) error {
return s.db.Delete(&models.Task{}, id).Error
}Create() method. Need to send a notification when a task is completed? Add it in Update(). Keep app.go thin — it just delegates.Challenge: Read the Service
Open the task service at internal/services/task_service.go. Read the List() method. What GORM query does it use? What order does it sort by?
Data Types and Field Mapping
When you define fields in grit generate resource, each type maps through three layers:
| Grit Type | Go Type | SQLite Type | TypeScript Type |
|---|---|---|---|
| string | string | TEXT | string |
| text | string | TEXT | string |
| int | int | INTEGER | number |
| float | float64 | REAL | number |
| bool | bool | INTEGER (0/1) | boolean |
| date | *time.Time | DATETIME | string (ISO 8601) |
| richtext | string | TEXT | string (HTML) |
Challenge: Type Mapping Quiz
Without looking at the table: What SQLite type does a float64 become? What about *time.Time? What Go type does bool map to? Check your answers against the table above.
Working with Relationships
You can create related resources using the belongs_to field type. For example, a Note that belongs to a Category:
grit generate resource Category --fields "name:string:unique"
grit generate resource Note --fields "title:string,content:richtext,category:belongs_to:Category"This creates a CategoryID foreign key on the Note model and sets up the GORM relationship. The generated UI includes a dropdown to select the category when creating or editing a note.
Challenge: Create Related Resources
Generate a Category resource with a name field, then generate a Note resource with title:string,content:text,category:belongs_to:Category. Create 3 categories and 5 notes. Does the note form show a category dropdown?
Removing Resources
Made a mistake? The grit remove resource command cleanly removes everything that was generated:
grit remove resource TaskThis removes the model file, service file, types file, route files, the sidebar entry, and the bindings in app.go. It's a clean undo of grit generate resource.
Challenge: Remove and Recreate
Remove the Task resource with grit remove resource Task. Verify: is the model file gone? Are the route files gone? Is the sidebar entry removed? Now generate it again with different fields: grit generate resource Task --fields "title:string,due_date:date,status:string,done:bool".
Database Migrations
GORM auto-migrates your models when the app starts. When you add a new field to a model, restart the app and GORM adds the column to the SQLite database automatically.
.db file and let GORM recreate it from scratch. In development, this is fine — your seed data will repopulate.Challenge: Watch Auto-Migration
Add a new field to your Task model manually: Notes string `gorm:"type:text" json:"notes"`. Restart the app. Open GORM Studio — is the new column there? Create a task with notes to confirm it works.
What You Learned
- How desktop CRUD differs from web CRUD — direct function calls vs HTTP
- How to generate resources with
grit generate resourcefor desktop - How Wails bindings work — Go methods become TypeScript functions
- GORM models with SQLite — same patterns, minor differences
- TanStack Router file-based routing for desktop apps
- The service layer pattern — business logic separated from bindings
- Data type mappings from Grit types to Go, SQLite, and TypeScript
- How to remove resources cleanly with
grit remove resource
Challenge: Build a Notes App
Starting from a fresh desktop project, generate two resources: Category --fields "name:string:unique" and Note --fields "title:string,content:richtext,category:belongs_to:Category,pinned:bool,color:string:optional". Create 3 categories and 10 notes across those categories.
Challenge: Verify in GORM Studio
Open GORM Studio with grit studio. Browse the notes table. Can you see the category_id foreign key? Filter notes by category. Export the data if Studio supports it.
Challenge: Test the Full Cycle
Create a note, edit it (change the title and category), then delete it. Verify each operation in GORM Studio. Does the note count on the dashboard update after each operation?
Enjoying the course?
Help us grow — star us on GitHub, subscribe on YouTube, and follow on LinkedIn.