Courses/Grit Desktop/Desktop CRUD & Data
Course 2 of 5~30 min12 challenges

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:

OperationWeb App (HTTP)Desktop App (Wails)
Listfetch("/api/tasks")GetTasks()
Createfetch("/api/tasks", { method: "POST" })CreateTask({ title: "..." })
Updatefetch("/api/tasks/1", { method: "PUT" })UpdateTask(1, { title: "..." })
Deletefetch("/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:

Terminal
grit generate resource Task --fields "title:string,description:text,done:bool,priority:int"

This creates everything you need for a full Task CRUD:

FilePurpose
internal/models/task.goGORM model with struct tags
internal/services/task_service.goBusiness logic (List, Create, GetByID, Update, Delete)
internal/types/task_types.goInput/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.tsxSidebar entry injected automatically
1

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:

app.go (Task methods)
// 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:

frontend/src/routes/tasks/index.tsx
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)
Wails Bindings: Auto-generated TypeScript wrappers for your Go methods. Wails reads your Go code, finds all public methods on bound structs, and creates matching TypeScript functions in thewailsjs/go/ directory. These functions handle serialization and deserialization automatically — you pass JavaScript objects and get JavaScript objects back.
2

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:

internal/models/task.go
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"`
}
SQLite has some differences from PostgreSQL. There is no 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.
3

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

TanStack Router: A type-safe router for React applications. It uses file-based routing — each file in the routes directory becomes a URL. TanStack Router is the router used in Grit desktop apps (Next.js App Router is used for web apps).
File-based Routing: A convention where the file structure in your routes folder directly maps to URL paths. A file at 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/:

Route Structure
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.tsx

The $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.

4

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:

internal/services/task_service.go (simplified)
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
}
The service layer is where you add custom business rules. Need to validate that priority is between 1 and 5? Add it in the service's Create() method. Need to send a notification when a task is completed? Add it in Update(). Keep app.go thin — it just delegates.
5

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 TypeGo TypeSQLite TypeTypeScript Type
stringstringTEXTstring
textstringTEXTstring
intintINTEGERnumber
floatfloat64REALnumber
boolboolINTEGER (0/1)boolean
date*time.TimeDATETIMEstring (ISO 8601)
richtextstringTEXTstring (HTML)
6

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:

Terminal
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.

7

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:

Terminal
grit remove resource Task

This 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.

8

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.

SQLite does not support dropping columns or changing column types through GORM auto-migrate. If you need to make destructive changes, delete the .db file and let GORM recreate it from scratch. In development, this is fine — your seed data will repopulate.
9

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 resource for 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
10

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.

11

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.

12

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?