📝
Svelte × Go × Pico.css で作る実践 ToDoアプリ —— ステップバイステップで学ぶハンズオン
はじめに
以前の記事では Svelte のフォームから Go API を叩いて、エコー応答を受け取る というシンプルな例を紹介しました。
今回はもう少し発展させて、ToDo アプリを題材にします。
- フロント:Svelte + pico.css
- バックエンド:Go(標準ライブラリのみ)
- 機能:タスクの追加・一覧表示・削除
0. 事前準備
- Node.js 18+(node -v で確認)
- Go 1.21+(go version で確認)
1. プロジェクト作成(Vite × Svelte)
# 1) Svelte プロジェクトを作る
npm create vite@latest svelte-todo -- --template svelte
cd svelte-todo
npm install
# 2) 開発サーバーの起動(確認)
npm run dev
# → http://localhost:5173 が開ければOK
pico.css を読み込み
index.html を開き、 <head> に1行追加します。
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css">
2. Go API を作る(追加・一覧・削除 + CORS)
フロントとは別フォルダで OK です。ここでは、リポジトリ直下に server/ を作る想定にします。
mkdir server && cd server
go mod init todoapi
touch main.go
server/main.go:
package main
import (
"encoding/json"
"net/http"
"sync"
)
type Task struct {
ID int `json:"id"`
Text string `json:"text"`
}
var (
tasks []Task
mu sync.Mutex
nextId = 1
)
// --- Handlers ---
func listHandler(w http.ResponseWriter, r *http.Request) {
// GET 専用
if r.Method != http.MethodGet {
http.Error(w, "only GET allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
mu.Lock()
defer mu.Unlock()
json.NewEncoder(w).Encode(tasks)
}
func addHandler(w http.ResponseWriter, r *http.Request) {
// POST専用
if r.Method != http.MethodPost {
http.Error(w, "only POST allowed", http.StatusMethodNotAllowed)
return
}
var t Task
if err := json.NewDecoder(r.Body).Decode(&t); err != nil || t.Text == "" {
http.Error(w, "invalid input", http.StatusBadRequest)
return
}
mu.Lock()
t.ID = nextId
nextId++
tasks = append(tasks, t)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(t)
}
func deleteHandler(w http.ResponseWriter, r *http.Request) {
// POST専用
if r.Method != http.MethodPost {
http.Error(w, "only POST allowed", http.StatusMethodNotAllowed)
return
}
var req struct{ ID int }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ID == 0 {
http.Error(w, "invalid input", http.StatusBadRequest)
return
}
mu.Lock()
defer mu.Unlock()
newTasks := make([]Task, 0, len(tasks))
for _, t := range tasks {
if t.ID != req.ID {
newTasks = append(newTasks, t)
}
}
tasks = newTasks
w.WriteHeader(http.StatusNoContent)
}
// --- CORS middleware ---
// Vite(Svelte) 開発サーバーのオリジンだけ許可します。
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "http://localhost:5173" || origin == "http://127.0.0.1:5173" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
// プリフライト
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/list", listHandler)
mux.HandleFunc("/api/add", addHandler)
mux.HandleFunc("/api/delete", deleteHandler)
// :8080 で待受
http.ListenAndServe(":8080", cors(mux))
}
解説ポイント
- スレッドセーフ:mu sync.Mutex で配列 tasks への同時アクセスを保護
- CORS:OPTIONS(プリフライト)を 204 で即返し、POST/GET を許可
- 削除は POST:簡単にするため JSON { "id": 3 } を受け取る方式に
サーバー起動
go run main.go
# → :8080 で待ち受け
3. Svelte 側の UI 実装(フォーム + 一覧 + 削除)
svelte-todo/src/App.svelte をまるごと置き換え:
<script>
import { onMount } from "svelte";
const API = "http://localhost:8080";
let tasks = [];
let newTask = "";
let loading = false;
let error = "";
async function fetchTasks() {
error = "";
try {
const res = await fetch(`${API}/api/list`);
if (!res.ok) throw new Error("list failed");
tasks = await res.json();
} catch (e) {
error = "読み込みに失敗しました";
console.error(e);
}
}
async function addTask() {
if (!newTask) return;
loading = true;
error = "";
try {
const res = await fetch(`${API}/api/add`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: newTask })
});
if (!res.ok) throw new Error("add failed");
newTask = "";
await fetchTasks();
} catch(e) {
error = "追加に失敗しました";
console.error(e);
} finally {
loading = false;
}
}
async function deleteTask(id) {
loading = true;
error = "";
try {
const res = await fetch(`${API}/api/delete`, {
method: "POST",
headers: { "Content-Type": "application/json"},
body: JSON.stringify({ id})
});
if (!res.ok && res.status !== 204) throw new Error("delete failed");
await fetchTasks();
}catch(e) {
error = "削除に失敗しました";
console.error(e);
}finally{
loading = false;
}
}
onMount(fetchTasks);
</script>
<main class="container">
<h1>ToDo アプリ(Svelte x Go x Pico.css)</h1>
{#if error}
<article class="contrast">
<strong>エラー:</strong> {error}
</article>
{/if}
<form on:submit|preventDefault={addTask}>
<label>
新しいタスク
<input type="text" bind:value={newTask} placeholder="やることを入力" />
</label>
<button type="submit" disabled={loading||!newTask}>追加</button>
</form>
<hr />
{#if tasks.length === 0}
<p>タスクはまだありません。</p>
{:else}
<ul>
{#each tasks as task}
<li>
<span>{task.text}</span>
<button class="secondary" on:click={() => deleteTask(task.id)} disabled={loading}>
削除
</button>
</li>
{/each}
</ul>
{/if}
</main>
コードの要点
• onMount(fetchTasks):初回レンダリング後に一覧取得
• ローディング制御:連打や二重送信を防止
• pico.css:container, form, button, ul/li など素の HTML をキレイに
CORSエラーになったら?
Dev サーバー(5173)と API(8080)はオリジンが違います。
Go 側 CORS 中間層の Origin 許可に http://localhost:5173 が入っているか確認してください。
プリフライト(OPTIONS)に 204 を返す実装も必須です。
4. 動作確認
サーバー起動
# 別ターミナル
cd server
go run main.go
フロント起動
cd svelte-todo
npm run dev
# http://localhost:5173 を開く
- タスクを入力して「追加」
- 一覧に表示される
- 「削除」ボタンで消える
API 単体テスト
# 追加
curl -X POST http://localhost:8080/api/add \
-H 'Content-Type: application/json' \
-d '{"text":"買い物に行く"}'
# 一覧
curl http://localhost:8080/api/list
# 削除(id=1 例)
curl -X POST http://localhost:8080/api/delete \
-H 'Content-Type: application/json' \
-d '{"id":1}'
まとめ
- Svelte で軽快な UI、pico.css で即キレイ
- Go の標準ライブラリだけで API(追加・一覧・削除)を作成
- CORS を正しく通してローカル別ポート間の通信を安定化
「動いた」が積み上がると、次の拡張が怖くなくなります。ここから先は、データ永続化や認証に挑戦してみましょう!
Discussion