Lewati ke konten

Daftar TODO

Dalam tutorial ini, Anda akan membangun aplikasi daftar TODO yang sepenuhnya fungsional. Ini langkah lebih lanjut dari tutorial Layanan QR Code — Anda akan belajar mengelola state, menangani banyak operasi, dan membuat antarmuka pengguna yang rapi.

Yang akan Anda bangun:

  • Aplikasi TODO lengkap dengan fungsi tambah, selesai, dan hapus
  • Manajemen state thread-safe (penting untuk aplikasi desktop)
  • Desain UI glassmorphic modern
  • Semua menggunakan vanilla JavaScript — tidak perlu framework

Yang akan Anda pelajari:

  • Operasi CRUD (Create, Read, Update, Delete)
  • Mengelola state mutable dengan aman di Go
  • Menangani input pengguna dan validasi
  • Membangun UI responsif yang terasa native
Aplikasi Daftar TODO

Waktu penyelesaian: 20 menit

  1. Buat proyek

    Pertama, buat proyek Wails baru. Kita akan menggunakan template vanilla default yang memberikan titik awal bersih:

    Terminal window
    wails3 init -n todo-app
    cd todo-app

    Ini membuat proyek baru dengan struktur dasar: backend Go di root, kode frontend di direktori frontend/.

  2. Buat layanan TODO

    Layanan TODO akan mengelola state aplikasi kita dan menyediakan metode untuk operasi CRUD. Berbeda dengan web server di mana setiap permintaan terisolasi, aplikasi desktop dapat memiliki banyak operasi konkuren, jadi kita perlu manajemen state thread-safe.

    Hapus greetservice.go dan buat file baru todoservice.go:

    todoservice.go
    package main
    import (
    "errors"
    "sync"
    )
    type Todo struct {
    ID int `json:"id"`
    Title string `json:"title"`
    Completed bool `json:"completed"`
    }
    type TodoService struct {
    todos []Todo
    nextID int
    mu sync.RWMutex
    }
    func NewTodoService() *TodoService {
    return &TodoService{
    todos: []Todo{},
    nextID: 1,
    }
    }
    func (t *TodoService) GetAll() []Todo {
    t.mu.RLock()
    defer t.mu.RUnlock()
    return t.todos
    }
    func (t *TodoService) Add(title string) (*Todo, error) {
    if title == "" {
    return nil, errors.New("title cannot be empty")
    }
    t.mu.Lock()
    defer t.mu.Unlock()
    todo := Todo{
    ID: t.nextID,
    Title: title,
    Completed: false,
    }
    t.todos = append(t.todos, todo)
    t.nextID++
    return &todo, nil
    }
    func (t *TodoService) Toggle(id int) error {
    t.mu.Lock()
    defer t.mu.Unlock()
    for i := range t.todos {
    if t.todos[i].ID == id {
    t.todos[i].Completed = !t.todos[i].Completed
    return nil
    }
    }
    return errors.New("todo not found")
    }
    func (t *TodoService) Delete(id int) error {
    t.mu.Lock()
    defer t.mu.Unlock()
    for i, todo := range t.todos {
    if todo.ID == id {
    t.todos = append(t.todos[:i], t.todos[i+1:]...)
    return nil
    }
    }
    return errors.New("todo not found")
    }

    Apa yang terjadi di sini:

    Struct Todo:

    • Mendefinisikan bentuk data dengan field ID, Title, dan Completed
    • Tag json: memberi tahu Go cara mengonversi struct ini ke JSON untuk frontend
    • Setiap field diekspor (kapital) agar generator binding dapat melihatnya

    Struct TodoService:

    • todos []Todo — slice yang menampung semua item TODO
    • nextID int — melacak ID berikutnya yang akan ditetapkan (mensimulasikan auto-increment)
    • mu sync.RWMutex — read/write mutex untuk akses thread-safe

    Thread safety dengan sync.RWMutex:

    • Aplikasi desktop dapat memiliki banyak operasi konkuren dari UI
    • RLock() memungkinkan banyak reader sekaligus (mis. beberapa pemanggilan GetAll)
    • Lock() memberikan akses eksklusif untuk write (mis. Add, Toggle, Delete)
    • defer memastikan lock dilepas meskipun fungsi return lebih awal atau panic

    Metode:

    • GetAll() — Mengembalikan semua todo (menggunakan read lock karena tidak memodifikasi data)
    • Add(title) — Membuat todo baru, memvalidasi input, menaikkan ID
    • Toggle(id) — Membalik status completed todo
    • Delete(id) — Menghapus todo dari slice

    Penanganan error:

    • Kita mengembalikan error sebagai nilai terakhir mengikuti konvensi Go
    • Judul kosong ditolak
    • Operasi pada todo yang tidak ada mengembalikan error
    • Error ini menjadi exception JavaScript di frontend
  3. Perbarui main.go

    Daftarkan layanan TODO ke aplikasi Wails Anda. Temukan bagian Services di main.go dan ganti GreetService dengan TodoService kita:

    main.go
    Services: []application.Service{
    application.NewService(NewTodoService()),
    },

    Apa yang terjadi di sini:

    • Kita menghapus GreetService default dan menambahkan TodoService kita
    • application.NewService() membungkus service agar Wails dapat mengelolanya
    • Wails akan otomatis membuat binding JavaScript untuk semua metode publik pada service ini
  4. Buat UI frontend

    Sekarang mari bangun frontend. Di sini kita akan memanggil metode Go dan menampilkan UI. Kita menggunakan vanilla JavaScript agar sederhana dan menunjukkan cara kerja binding secara langsung.

    Ganti frontend/src/main.js:

    frontend/src/main.js
    import {TodoService} from "../bindings/changeme";
    async function loadTodos() {
    const todos = await TodoService.GetAll();
    const list = document.getElementById('todo-list');
    list.innerHTML = todos.map(todo => `
    <div class="todo ${todo.completed ? 'completed' : ''}">
    <input type="checkbox"
    ${todo.completed ? 'checked' : ''}
    onchange="toggleTodo(${todo.id})">
    <span>${todo.title}</span>
    <button onclick="deleteTodo(${todo.id})">Delete</button>
    </div>
    `).join('');
    }
    window.addTodo = async () => {
    const input = document.getElementById('todo-input');
    const title = input.value.trim();
    if (title) {
    await TodoService.Add(title);
    input.value = '';
    await loadTodos();
    }
    }
    window.toggleTodo = async (id) => {
    await TodoService.Toggle(id);
    await loadTodos();
    }
    window.deleteTodo = async (id) => {
    await TodoService.Delete(id);
    await loadTodos();
    }
    // Load todos on startup
    loadTodos();

    Apa yang terjadi di sini:

    Mengimpor binding:

    • import {TodoService} from "../bindings/changeme" — membawa binding Go yang dibuat otomatis
    • Catatan: changeme akan menjadi nama modul aktual Anda dari go.mod

    Fungsi loadTodos():

    • Memanggil TodoService.GetAll() untuk mengambil semua todo dari Go
    • Membangun HTML untuk setiap todo menggunakan template literal
    • Menambah/menghapus class completed secara dinamis untuk styling
    • Menggunakan atribut onclick untuk menghubungkan tombol ke fungsi kita
    • Menggabungkan semua HTML dan menyuntikkannya ke DOM

    Fungsi CRUD:

    • addTodo() — Memvalidasi input, memanggil metode Go Add, me-refresh daftar
    • toggleTodo(id) — Memanggil metode Go Toggle, me-refresh daftar
    • deleteTodo(id) — Memanggil metode Go Delete, me-refresh daftar
    • Semua fungsi async karena pemanggilan Go mengembalikan Promise

    Mengapa attach ke window:

    • window.addTodo = ... membuat fungsi dapat diakses dari atribut onclick HTML
    • Ini pola sederhana untuk vanilla JS (framework menanganinya berbeda)
    • Di produksi, Anda mungkin menggunakan event delegation yang proper

    Pola refresh:

    • Setelah setiap mutasi (add/toggle/delete), kita panggil loadTodos() lagi
    • Ini memastikan UI tetap sinkron dengan state Go
    • Alternatif: Buat metode Go mengembalikan state baru untuk menghindari pemanggilan kedua
  5. Perbarui HTML

    HTML menyediakan struktur untuk aplikasi TODO kita. Minimal dan semantik — keajaiban terjadi di JavaScript dan CSS.

    Ganti frontend/index.html:

    frontend/index.html
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>TODO App</title>
    <link rel="stylesheet" href="./style.css"/>
    </head>
    <body>
    <div class="container">
    <h1>My TODOs</h1>
    <div class="card">
    <div class="input-box">
    <input type="text"
    id="todo-input"
    class="input"
    placeholder="Add a new todo..."
    onkeypress="if(event.key==='Enter') addTodo()">
    <button class="btn" onclick="addTodo()">Add</button>
    </div>
    <div id="todo-list"></div>
    </div>
    </div>
    <script type="module" src="./src/main.js"></script>
    </body>
    </html>

    Apa yang terjadi di sini:

    Struktur:

    • container — menengahkan aplikasi dan membatasi lebar
    • card — kartu putih utama yang menampung semuanya
    • input-box — container flex untuk input dan tombol Add
    • todo-list — tempat todo individual disuntikkan oleh JavaScript

    Penanganan event:

    • onkeypress="if(event.key==='Enter') addTodo()" — tambah todo saat Enter ditekan
    • onclick="addTodo()" — tambah todo saat tombol diklik
    • Inline event handler bekerja baik untuk aplikasi vanilla JS sederhana

    Module script:

    • <script type="module"> memungkinkan kita menggunakan import ES6
    • File main.js kita dapat mengimpor binding dan menggunakan JavaScript modern
  6. Style aplikasi

    CSS membuat desain glassmorphic modern dengan transisi halus. Kita menargetkan nuansa rapi yang membuat aplikasi menyenangkan digunakan.

    Ganti frontend/public/style.css:

    frontend/public/style.css
    :root {
    font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
    "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
    sans-serif;
    font-size: 16px;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: rgba(255, 255, 255, 0.87);
    }
    body {
    margin: 0;
    display: flex;
    place-items: center;
    justify-content: center;
    min-height: 100vh;
    }
    .container {
    width: 100%;
    max-width: 600px;
    padding: 20px;
    }
    h1 {
    text-align: center;
    color: white;
    font-size: 2.5em;
    font-weight: 300;
    margin: 0 0 30px 0;
    text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
    }
    .card {
    background: rgba(255, 255, 255, 0.95);
    backdrop-filter: blur(10px);
    border-radius: 16px;
    padding: 30px;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
    }
    .input-box {
    display: flex;
    gap: 10px;
    margin-bottom: 25px;
    }
    .input {
    flex: 1;
    border: 2px solid #e0e0e0;
    border-radius: 12px;
    height: 50px;
    padding: 0 20px;
    font-size: 16px;
    transition: all 0.3s ease;
    }
    .input:focus {
    border-color: #667eea;
    outline: none;
    box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
    }
    .btn {
    height: 50px;
    padding: 0 30px;
    border: none;
    border-radius: 12px;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: white;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.3s ease;
    box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
    }
    .btn:hover {
    transform: translateY(-2px);
    box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
    }
    #todo-list {
    display: flex;
    flex-direction: column;
    gap: 10px;
    }
    .todo {
    display: flex;
    align-items: center;
    padding: 18px 20px;
    background: white;
    border: 2px solid #f0f0f0;
    border-radius: 12px;
    transition: all 0.3s ease;
    gap: 15px;
    }
    .todo:hover {
    border-color: #667eea;
    box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
    transform: translateX(4px);
    }
    .todo.completed {
    opacity: 0.6;
    }
    .todo.completed span {
    text-decoration: line-through;
    color: #999;
    }
    .todo input[type="checkbox"] {
    width: 24px;
    height: 24px;
    cursor: pointer;
    appearance: none;
    -webkit-appearance: none;
    border: 2px solid #667eea;
    border-radius: 6px;
    position: relative;
    transition: all 0.3s ease;
    flex-shrink: 0;
    }
    .todo input[type="checkbox"]:hover {
    background: rgba(102, 126, 234, 0.1);
    }
    .todo input[type="checkbox"]:checked {
    background: #667eea;
    border-color: #667eea;
    }
    .todo input[type="checkbox"]:checked::after {
    content: '';
    position: absolute;
    color: white;
    font-size: 16px;
    font-weight: bold;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    }
    .todo span {
    flex: 1;
    font-size: 16px;
    color: #333;
    }
    .todo button {
    padding: 8px 16px;
    background: #ff4757;
    color: white;
    border: none;
    border-radius: 8px;
    font-size: 14px;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.3s ease;
    opacity: 0;
    flex-shrink: 0;
    }
    .todo:hover button {
    opacity: 1;
    }
    .todo button:hover {
    background: #ee5a6f;
    transform: scale(1.05);
    }
    #todo-list:empty::before {
    content: "No todos yet. Add one above!";
    display: block;
    text-align: center;
    padding: 40px 20px;
    color: #999;
    }

    Apa yang terjadi di sini:

    Desain glassmorphic:

    • background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) — latar belakang gradien ungu
    • backdrop-filter: blur(10px) — efek kaca buram pada kartu
    • rgba(255, 255, 255, 0.95) — putih semi-transparan untuk efek kaca

    Styling checkbox kustom:

    • appearance: none menghapus checkbox browser default
    • Kita membuat kotak bulat kustom dengan centang menggunakan ::after
    • Centang muncul saat checked menggunakan karakter unicode ✓

    Interaksi hover:

    • Todo bergeser ke kanan saat hover (transform: translateX(4px))
    • Tombol hapus tersembunyi sampai hover (opacity: 0opacity: 1)
    • Tombol sedikit membesar saat hover untuk feedback taktil

    Empty state:

    • #todo-list:empty::before menampilkan pesan saat tidak ada todo
    • Solusi CSS-only — tidak perlu JavaScript
  7. Jalankan aplikasi

    Mari lihat hasilnya! Jalankan development server:

    Terminal window
    wails3 dev

    Aplikasi akan dikompilasi dan terbuka. Coba:

    • Ketik todo dan tekan Enter atau klik Add
    • Klik checkbox untuk menandai selesai
    • Arahkan kursor ke todo untuk melihat tombol hapus muncul
    • Perhatikan UI diperbarui instan — itu pola refresh kita bekerja

    Apa yang terjadi:

    • Wails otomatis membuat binding untuk metode TodoService Anda
    • Mode dev termasuk hot reload — coba ubah CSS dan lihat pembaruannya
    • Kode Go Anda berjalan secara native — tidak perlu translasi atau interpretasi

sync.RWMutex menyediakan akses konkuren yang aman:

func (t *TodoService) GetAll() []Todo {
t.mu.RLock() // Read lock - multiple readers allowed
defer t.mu.RUnlock()
return t.todos
}
func (t *TodoService) Add(title string) (*Todo, error) {
t.mu.Lock() // Write lock - exclusive access
defer t.mu.Unlock()
// ... mutations
}

Mengapa ini penting:

  • Beberapa pemanggilan frontend dapat terjadi secara konkuren
  • Operasi baca tidak saling memblokir
  • Operasi tulis mendapat akses eksklusif
  • defer memastikan lock selalu dilepas

Service mengembalikan error untuk operasi tidak valid:

func (t *TodoService) Add(title string) (*Todo, error) {
if title == "" {
return nil, errors.New("title cannot be empty")
}
// ...
}

Di frontend, Anda dapat menangkapnya:

try {
await TodoService.Add(title);
} catch (err) {
alert('Error: ' + err);
}

Setelah setiap mutasi, kita muat ulang daftar lengkap:

window.addTodo = async () => {
await TodoService.Add(title); // Mutation
await loadTodos(); // Refresh
}

Pendekatan alternatif: Kembalikan daftar yang diperbarui dari setiap metode untuk menghindari pemanggilan kedua.

Tambahkan ini ke todoservice.go:

type TodoStats struct {
Total int `json:"total"`
Completed int `json:"completed"`
Active int `json:"active"`
}
func (t *TodoService) GetStats() TodoStats {
t.mu.RLock()
defer t.mu.RUnlock()
stats := TodoStats{
Total: len(t.todos),
}
for _, todo := range t.todos {
if todo.Completed {
stats.Completed++
} else {
stats.Active++
}
}
return stats
}

Tampilkan di frontend:

async function loadTodos() {
const [todos, stats] = await Promise.all([
TodoService.GetAll(),
TodoService.GetStats()
]);
// Display stats
document.getElementById('stats').textContent =
`${stats.active} active, ${stats.completed} completed`;
// ... render todos
}
func (t *TodoService) ClearCompleted() int {
t.mu.Lock()
defer t.mu.Unlock()
removed := 0
newTodos := []Todo{}
for _, todo := range t.todos {
if !todo.Completed {
newTodos = append(newTodos, todo)
} else {
removed++
}
}
t.todos = newTodos
return removed
}

Untuk aplikasi produksi, Anda biasanya menambahkan persistensi database. Lihat panduan Integrasi Database untuk contoh dengan SQLite, PostgreSQL, dll.

Saat siap mendistribusikan aplikasi TODO Anda, build untuk produksi:

Terminal window
wails3 build

Ini membuat executable native yang dioptimalkan di bin/:

  • Mengompilasi kode Go dengan optimasi
  • Membangun frontend untuk produksi
  • Mengemas semuanya ke satu executable
  • Aplikasi hasilnya biasanya 10-20MB (bandingkan dengan Electron 150MB+)

Anda dapat menjalankan executable langsung — tidak perlu runtime, tidak perlu server. Ini aplikasi native sejati.

Anda baru saja membangun aplikasi TODO lengkap dengan:

Implementasi CRUD penuh:

  • Membuat service dengan operasi Create, Read, Update, dan Delete
  • Menambahkan validasi input dan penanganan error
  • Memahami bagaimana error Go menjadi exception JavaScript

Manajemen state thread-safe:

  • Menggunakan sync.RWMutex untuk menangani akses konkuren dengan aman
  • Memahami perbedaan antara read lock (RLock) dan write lock (Lock)
  • Melihat bagaimana defer mencegah deadlock dengan menjamin cleanup

UI modern dan rapi:

  • Membangun antarmuka glassmorphic dengan gradien dan efek blur
  • Membuat checkbox bergaya kustom tanpa framework
  • Menambahkan interaksi hover dan transisi untuk nuansa native
  • Mengimplementasikan empty state dengan CSS murni

Dasar-dasar Wails:

  • Pendaftaran service dan pembuatan binding otomatis
  • Memanggil metode Go dari JavaScript dengan async/await
  • Sinkronisasi state antara Go dan frontend
  • Membangun dan mengemas aplikasi desktop native

Setelah memahami operasi CRUD dan manajemen state, coba:

  • Tambahkan persistensi: Buat todo bertahan setelah restart aplikasi dengan SQLite
  • Tambahkan fitur lain: Filtering (all/active/completed), mengedit todo yang ada, operasi bulk
  • Jelajahi tutorial Notes: Lihat cara kerja operasi file di Notes
  • Bangun sesuatu yang nyata: Ambil konsep ini dan bangun aplikasi Anda sendiri!