📝

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