React脳のままSvelteを書いて事故った僕が掴んだ、Claude Codeでの移行のコツ
React経験者がSvelte 5 runes($state/$derived/$effect)とSvelteKitルーティングでつまずく所を、Claude Codeで安全に書く頼み方ごと解説。
「Svelteなんて、Reactできれば余裕でしょ」
そう思って初めてのSvelteKitプロジェクトに飛び込んだ僕は、useState のクセが抜けないまま $state を書き、useEffect の感覚で $effect の中に状態更新を詰め込み、気づけば無限ループでブラウザのタブを固めていました。
JSXもない。useMemo もない。なのに画面はちゃんと再描画される。最初は「魔法かよ」と思ったんですが、種が分かると拍子抜けするくらいシンプルでした。Svelteはコンパイラで、Reactはランタイム。この一行の違いが、書き方のほぼ全部を説明してくれます。
この記事は、React経験者が同じ穴に落ちないための移行メモです。そしてClaude CodeにSvelteを書かせるとき、放っておくと平気で古い書き方に戻すので、その手綱の握り方もセットで書きます。
この記事の要点
- Svelteはビルド時にコンパイルしてリアクティビティを埋め込む。Reactの「再レンダリングで差分を取る」モデルとは根っこが違う。
- Svelte 5の
$state(変わる元データ)・$derived(自動計算される値)・$effect(外部との同期だけ)は、React Hooksと似て非なるもの。特に$derivedはuseMemoより素直。 - SvelteKitはファイル名がルートになる。
+page.svelte/+page.server.ts/+server.tsの役割分担と、サーバー/クライアント境界が肝。 - Claude Codeには「runes構文を維持」「
export letに戻さない」「npm run checkが通るまで」と境界を明記して頼む。これだけで手戻りが激減する。 - 移行は一括変換しない。1コンポーネントずつ、テストで挙動を固定しながら進める。
まず大前提:コンパイラとランタイムの違い
ここを飛ばすと、ずっと「Reactっぽく書いて、なんか違う」を繰り返します。
Reactはアプリの実行中に動くランタイムです。状態が変わると関数コンポーネントをまるごと再実行し、仮想DOMで前回との差分を計算し、必要な部分だけ本物のDOMに反映する。賢いけど、その賢さのためのコードをブラウザに積んでいます。
Svelteは違います。svelte という名のフレームワークの本体は、ビルド時に消えます。あなたが書いた count が変わったら、count を表示しているこのテキストノードだけを書き換える——そういう「素のJavaScript」をコンパイラが事前に生成する。だから出力されるバンドルは軽いし、仮想DOMの差分計算という中間処理がそもそも無い。
React脳で一番ハマるのはここです。Reactでは「再レンダリングを避けるために useMemo や useCallback で包む」のが日常でした。Svelteには再レンダリングという概念がほぼ無いので、その手の最適化のほとんどが不要になります。「メモ化どこ?」と探す時間が、まるごと消えます。
| 観点 | React | Svelte 5 |
|---|---|---|
| 動く場所 | ランタイム(ブラウザで実行) | コンパイラ(ビルド時に変換) |
| 更新の仕組み | 再レンダリング+仮想DOM差分 | 変わった値に紐づくDOMだけ更新 |
| 状態宣言 | useState | $state |
| 派生値 | useMemo | $derived |
| 副作用 | useEffect | $effect(用途は狭い) |
| props | 関数引数 | $props() |
| 子要素 | props.children | {@render children()} |
この表を頭の隅に置いておくだけで、移行のストレスが半分になります。
runesは「似てるけど別物」と割り切る
Svelte 5から入った runes(ルーン) は、リアクティブな値を宣言する記法です。$ で始まり、import不要で、コンパイラへの合図として働きます。React Hooksに見た目が似ているせいで、つい同じノリで書いてしまうのが罠です。
$state は「変わる元データ」。Reactの useState と違って、セッター関数は要りません。普通の変数のように count++ で書き換えられます。
$derived は「元データから自動計算される値」。useMemo に相当しますが、依存配列を書きません。コンパイラが「この式は何を参照しているか」を勝手に追跡してくれるので、[count] の付け忘れでバグる、というReactあるあるが起きません。
$effect は「外部との同期だけ」に使います。ここがReact経験者最大の落とし穴。useEffect をデータ計算やデータ取得に使い倒してきた人ほど、$effect に同じ仕事をさせようとして無限ループを作ります。表示用の計算は $derived、ブラウザAPIや外部ライブラリとの同期だけ $effect。この線引きを最初に刻んでください。
$props() は親から受け取る入力です。Svelte 4までの export let は捨てて、分割代入で受けます。
次の TaskCard.svelte は、そのまま src/lib/components/TaskCard.svelte に置ける小さなコンポーネントです。$props で受け取り、$derived で表示用ラベルを組み立てています。$effect も useMemo も出てこないのがポイントです。
<!-- src/lib/components/TaskCard.svelte -->
<script lang="ts">
type Task = {
id: string;
title: string;
done: boolean;
estimateMinutes: number;
tags: string[];
};
// 親から受け取る入力。export let ではなく $props() で分割代入する
let {
task,
onToggle
}: {
task: Task;
onToggle: (id: string) => void;
} = $props();
// 元データ(task)から自動計算される表示用の値。依存配列は書かない
let statusLabel = $derived(task.done ? '完了' : '未完了');
let estimateLabel = $derived(`${Math.ceil(task.estimateMinutes / 15) * 15}分枠`);
</script>
<article class:done={task.done} class="task-card">
<div>
<p class="status">{statusLabel}</p>
<h3>{task.title}</h3>
<p>{estimateLabel}</p>
</div>
<ul aria-label="タグ">
{#each task.tags as tag}
<li>{tag}</li>
{/each}
</ul>
<!-- onClick ではなく onclick。Svelte 4 風の on:click には戻さない -->
<button type="button" aria-pressed={task.done} onclick={() => onToggle(task.id)}>
{task.done ? '未完了に戻す' : '完了にする'}
</button>
</article>
<style>
.task-card {
display: grid;
gap: 0.75rem;
border: 1px solid #ddd;
border-radius: 0.5rem;
padding: 1rem;
}
.done {
background: #f2fff5;
}
.status {
font-size: 0.875rem;
font-weight: 700;
}
ul {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
list-style: none;
padding: 0;
}
li {
border-radius: 999px;
background: #eef2ff;
padding: 0.2rem 0.6rem;
}
</style>
React出身者がよく驚くのは、CSSがコンポーネントファイルに同居していて、しかもそのコンポーネントにしか効かない点です。styled-components も CSS Modules も要りません。<style> を書けば、Svelteが自動でスコープを切ってくれます。
共有状態は「.svelte.ts」に置けるのが効く
Reactで状態を複数コンポーネントに配るとき、Context APIやZustand、Reduxを引っ張り出してきた人は多いはず。Svelte 5では、もっと素朴な手があります。
runesは .svelte.ts という拡張子のファイルの中でも使えます。これがReact経験者には地味に効きます。普通のモジュールに $state を置いて、複数のコンポーネントからimportするだけで、共有のリアクティブな状態になる。プロバイダーで全体をラップする儀式が要りません。
// src/lib/state/taskFilters.svelte.ts
export type TaskStatus = 'all' | 'open' | 'done';
// モジュールに置いた $state は、importした全コンポーネントで共有される
export const taskFilters = $state({
query: '',
status: 'all' as TaskStatus,
tag: ''
});
export function resetTaskFilters() {
taskFilters.query = '';
taskFilters.status = 'all';
taskFilters.tag = '';
}
呼ぶ側はこうです。bind:value で入力と状態を双方向に結べるのも、Reactの「value と onChange を毎回ペアで書く」手間からの解放です。
<!-- src/lib/components/TaskFilterPanel.svelte -->
<script lang="ts">
import { resetTaskFilters, taskFilters } from '$lib/state/taskFilters.svelte';
</script>
<section aria-label="タスク絞り込み">
<label>
キーワード
<input bind:value={taskFilters.query} placeholder="請求書、記事、レビューなど" />
</label>
<label>
状態
<select bind:value={taskFilters.status}>
<option value="all">すべて</option>
<option value="open">未完了</option>
<option value="done">完了</option>
</select>
</label>
<button type="button" onclick={resetTaskFilters}>リセット</button>
</section>
ここで一個、SSR(サーバーサイドレンダリング、初回表示をサーバー側で組み立てる仕組み)の罠があります。localStorage や window はブラウザにしか無いので、サーバー実行時に触ると落ちます。Next.jsで typeof window !== 'undefined' を書いてきたのと同じノリで、SvelteKitでは $app/environment の browser を見ます。
// src/lib/state/theme.svelte.ts
import { browser } from '$app/environment';
export const themeState = $state({
theme: 'system' as 'system' | 'light' | 'dark'
});
export function loadTheme() {
// サーバー実行時は localStorage が無いので即return する
if (!browser) return;
const saved = localStorage.getItem('theme');
if (saved === 'light' || saved === 'dark' || saved === 'system') {
themeState.theme = saved;
}
}
export function saveTheme(nextTheme: typeof themeState.theme) {
themeState.theme = nextTheme;
if (browser) localStorage.setItem('theme', nextTheme);
}
SvelteKitのルーティングはファイル名が仕様
Next.jsのApp Routerを触ったことがあれば、SvelteKitのファイルベースルーティングはすぐ馴染みます。フォルダがURLになり、特定のファイル名が特定の役割を持つ。
src/routes/about/+page.svelte→/aboutの画面src/routes/blog/[slug]/+page.svelte→[slug]が動的パラメータになるルート+page.server.ts→ サーバーだけで動くload関数(DB・秘密キーに触れる処理はここ)+page.ts→ サーバーとクライアント両方で動くload関数+server.ts→ GET/POST などを返すAPIエンドポイント+layout.svelte→ 複数ページで共有するUIの枠
React Routerで手書きしてきたルート定義配列は要りません。フォルダを切ればルートが生える。最初は「設定ファイルどこ?」と探しますが、無いんです。フォルダ構成そのものが設定です。
サーバー専用の処理は $lib/server に寄せるのが鉄則です。ここに置いたモジュールは、Svelteコンポーネントから誤ってimportするとビルドエラーになる。つまり「秘密をクライアントに漏らす事故」をフレームワークが物理的に止めてくれます。
// src/lib/server/tasks.ts
export type Task = {
id: string;
slug: string;
title: string;
done: boolean;
estimateMinutes: number;
tags: string[];
};
const tasks: Task[] = [
{
id: 'task-1',
slug: 'write-svelte-guide',
title: 'SvelteKit記事の下書きを作る',
done: false,
estimateMinutes: 45,
tags: ['writing', 'svelte']
}
];
export async function getTaskBySlug(slug: string) {
return tasks.find((task) => task.slug === slug) ?? null;
}
// src/routes/tasks/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getTaskBySlug } from '$lib/server/tasks';
// 画面が出る前にサーバー側でデータを用意する。$types は自動生成される
export const load: PageServerLoad = async ({ params }) => {
const task = await getTaskBySlug(params.slug);
if (!task) {
error(404, 'Task not found');
}
return { task };
};
<!-- src/routes/tasks/[slug]/+page.svelte -->
<script lang="ts">
import type { PageProps } from './$types';
// load が返したデータは data prop で受け取る
let { data }: PageProps = $props();
</script>
<svelte:head>
<title>{data.task.title} | Tasks</title>
</svelte:head>
<article>
<p>{data.task.done ? '完了' : '未完了'}</p>
<h1>{data.task.title}</h1>
<p>見積もり: {data.task.estimateMinutes}分</p>
</article>
load で先にデータを用意してから画面を描く流れは、Reactで useEffect 内 fetch →ローディング表示、をやってきた人には新鮮です。データが揃った状態でコンポーネントが立ち上がるので、「最初の一瞬だけ空」のチラつきと縁が切れます。
フォームは「JSなしでも動く」を土台にする
ここもReact出身者には発想の転換が要ります。Reactでは onSubmit を preventDefault して、fetch でAPIを叩いて、結果でstateを更新する——が当たり前でした。
SvelteKitのform actionsは逆向きです。まず素のHTMLフォームとして成立させ、その上に use:enhance でJavaScriptの体験を「上乗せ」する。だからJavaScriptが無効でも、回線が遅くても、フォームはちゃんとPOSTできる。これが堅牢さの源です。
// src/routes/contact/+page.server.ts
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions = {
default: async ({ request }) => {
const formData = await request.formData();
const values = {
name: String(formData.get('name') ?? '').trim(),
email: String(formData.get('email') ?? '').trim(),
message: String(formData.get('message') ?? '').trim()
};
// サーバー側で必ず検証する。クライアント検証だけに頼らない
const errors: Record<string, string> = {};
if (values.name.length < 2) errors.name = '名前は2文字以上で入力してください';
if (!values.email.includes('@')) errors.email = 'メールアドレスを確認してください';
if (values.message.length < 10) errors.message = '相談内容は10文字以上で入力してください';
if (Object.keys(errors).length > 0) {
// 入力値ごと返すと、画面側で打ち直しを防げる
return fail(400, { values, errors });
}
console.log('New inquiry', values);
return { success: true };
}
} satisfies Actions;
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
let { form }: PageProps = $props();
</script>
{#if form?.success}
<p role="status">送信しました。1から3営業日以内に返信します。</p>
{/if}
<!-- method="POST" だけでJSなしでも動く。use:enhance は体験の上乗せ -->
<form method="POST" use:enhance>
<label>
名前
<input name="name" value={form?.values?.name ?? ''} aria-invalid={!!form?.errors?.name} />
</label>
{#if form?.errors?.name}<p>{form.errors.name}</p>{/if}
<label>
メール
<input name="email" type="email" value={form?.values?.email ?? ''} aria-invalid={!!form?.errors?.email} />
</label>
{#if form?.errors?.email}<p>{form.errors.email}</p>{/if}
<label>
相談内容
<textarea name="message" rows="5" aria-invalid={!!form?.errors?.message}>{form?.values?.message ?? ''}</textarea>
</label>
{#if form?.errors?.message}<p>{form.errors.message}</p>{/if}
<button type="submit">送信する</button>
</form>
注意点は3つ。GET で副作用を起こさない。秘密キーをクライアント側に渡さない。{@html} に未検証の文字列を流し込まない(ReactでいうXSS対策の dangerouslySetInnerHTML と同じ警戒です)。
Claude CodeにSvelteを書かせるコツ
ここからが本題のもう半分です。Claude CodeはSvelteも普通に書けますが、放っておくと学習データに多いSvelte 4の書き方へ引っ張られることがあります。export let が復活したり、on:click に戻ったり。手綱を握る頼み方が要ります。
僕の定番は、まず編集させずに調査だけさせること。Claude Codeには、編集前に計画だけ立てるplan modeがあります。慣れるまでは claude --permission-mode plan で始め、提案に納得してから編集に進むと、ルーティングやサーバー処理を壊しにくくなります。
そして、プロジェクトの約束ごとは CLAUDE.md に書いておく。「Svelte 5のrunesを優先」「フォームはSvelteKit actions」「秘密情報は $lib/server から外に出さない」と一度書けば、毎回の依頼で繰り返さずに済みます。
下のテンプレートは、僕が実際に毎日使っている依頼文です。コピペして対象ファイルだけ書き換えてください。
SvelteKitの現在の構成を読んでから、次の範囲だけ変更してください。
対象: src/routes/contact/+page.svelte と src/routes/contact/+page.server.ts
目的: 問い合わせフォームに会社名フィールドを追加
制約:
- Svelte 5のrunes構文を維持し、export let や on:click に戻さない
- use:enhance を削除しない
- サーバー側バリデーションを追加
- 既存のCTA文言とスタイルは変更しない
- npm run check が通るまで直す
最後に変更点、リスク、追加で必要なテストを3行でまとめてください。
ポイントは「対象ファイルを絞る」「やってほしくないこと(戻さない・消さない)を名指しする」「通過条件(npm run check)を渡す」の3点。React前提の知識でSvelteを頼むと、つい曖昧な「いい感じに」を投げがちですが、Svelteは書き方の世代差が大きいぶん、制約を明記したほうが結果が安定します。
テストと確認コマンドで仕上げる
Svelte公式は、ViteやSvelteKitではVitestを使いやすい選択肢として挙げています。コンポーネントはTesting Libraryでユーザー操作に近い形で確認し、画面遷移はPlaywrightでE2Eに回すのが扱いやすい構成です。
// src/lib/components/TaskCard.test.ts
import { fireEvent, render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import TaskCard from './TaskCard.svelte';
describe('TaskCard', () => {
it('ボタンを押すとトグルされる', async () => {
let toggledId = '';
render(TaskCard, {
task: {
id: 'task-1',
title: 'SvelteKit記事を書く',
done: false,
estimateMinutes: 45,
tags: ['writing']
},
onToggle: (id) => {
toggledId = id;
}
});
await fireEvent.click(screen.getByRole('button', { name: '完了にする' }));
expect(toggledId).toBe('task-1');
});
});
変更の最後は、この4コマンドで自分の目で確かめます。Claude Code任せにせず、git diff だけは人間が読む。ここを省くと事故ります。
npm run check
npm run test
npm run build
git diff -- src/lib src/routes
React移行で僕がやらかした失敗3つ
正直に書きます。最初のSvelteKitプロジェクトは、React脳が抜けずに事故だらけでした。
ひとつ目は、$effect に計算をやらせたこと。useEffect の感覚で、$effect の中でフィルタ結果を別の $state に書き込んでいました。状態を更新すると $effect がまた走り、また更新し……で無限ループ。表示用の計算はすべて $derived に移したら、一発で直りました。
ふたつ目は、Claude Codeに丸投げして書き方が混ざったこと。「タスク一覧を作って」とだけ頼んだら、半分のコンポーネントが export let、半分が $props という地獄が出来上がりました。依頼文に「runesで統一、export let 禁止」と一行足すだけで、こんなことは起きません。
みっつ目は、サーバー専用コードをクライアントに漏らしかけたこと。DB接続のモジュールを、つい共通化しようと $lib 直下に置いてコンポーネントからimportした。SvelteKitがビルドで止めてくれたから助かりましたが、$lib/server に置く癖を最初からつけるべきでした。
よくある質問
Q. ReactをやめてSvelteに乗り換えるべき? A. 二択にしなくていいです。新規の小さめアプリや、バンドルを軽くしたい場面でSvelteを試し、Reactの資産はそのまま活かす。両方書ける人材価値は普通に高いです。
Q. Svelte 4で書かれた既存プロジェクトは全部runesに直すべき? A. 一括変換はおすすめしません。Svelte 4とSvelte 5の構文は共存できます。テストがあるコンポーネントから1つずつ、挙動を固定しながら移すのが安全です。
Q. $derived と $effect、どっちを使えばいいか毎回迷う。
A. 「画面に出す値の計算」なら必ず $derived。「ブラウザAPI・Canvas・外部ライブラリと状態を同期する」ときだけ $effect。データ取得は load 関数に寄せると、$effect の出番はぐっと減ります。
Q. $state で配列やオブジェクトを更新するとき、Reactみたいにイミュータブルにする必要は?
A. 不要です。tasks.push(...) や task.done = true のように、普通にミュータブルに書き換えてOK。Svelteが変更を検知して再描画します。スプレッド構文での作り直しは要りません。
Q. SvelteKitとNext.js、どっちが学習コスト低い? A. Next.jsのApp Routerに慣れていれば、ファイルベースルーティングとサーバー/クライアント境界の考え方はほぼ流用できます。runesの学び直しは要りますが、概念数は少ないので数日で慣れます。
実際に試した結果
検証用に小さなタスク管理アプリをSvelteKitで組んでみて、一番効いたのは「React脳を一度切る」ことでした。useMemo を探さない、$effect に計算をさせない、フォームはまずHTMLとして成立させる。この3つを意識した瞬間に、コードがすっと短くなったんです。
Claude Codeとの相性も良かったです。CLAUDE.md に「runes優先・export let 禁止・$lib/server を漏らさない」と書き、依頼文に「npm run check が通るまで」「コミットしない」を添える。たったこれだけで、Svelte初心者の僕でも差分を追える小ささに収まりました。賢いモデルに全部任せるより、戻せる小さな単位に切って手綱を握るほうが、結局いちばん速い——というのが今の実感です。
最新仕様は必ず一次情報で確認してください。Svelte公式ドキュメント、SvelteKit公式ドキュメント、SvelteKit form actions が出発点です。Claude Codeの基本操作は Claude Code入門ガイド、型の扱いは TypeScript活用テクニック、検証の組み方は テスト戦略ガイド も合わせてどうぞ。チーム導入で詰まったら Claude Code導入相談 から気軽に聞いてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。