#svelte (3)
Biome 2.xからSvelteファイルのフォーマットに対応した。biome.jsonで以下のように設定する。
{
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"files": {
"includes": ["**/*.svelte"]
}
}
ただし、Svelteの<script>タグ内のみがフォーマット対象。テンプレート部分(HTML)は対象外なので注意。
# フォーマット実行
pnpm biome format --write "src/**/*.svelte" Svelte 5でbind:ディレクティブを使う場合、バインド先の変数はletで宣言する必要がある。constだとコンパイルエラーになる。
<script>
// NG: constだとbind:できない
const name = $state('');
// OK: letで宣言
let name = $state('');
</script>
<input bind:value={name} />
Svelte 4ではexport letだったが、Svelte 5では$props()に変わったため、バインディング周りの挙動も変わっている。
概要
Phase 5.8で React 19 を完全脱却し、19コンポーネントを Astro + vanilla JS / Svelte 5 に移植。削除パッケージ数:10+、バンドルサイズ削減:30% 以上。
変換パターン別の実装解説
パターン1: React の状態管理 → data-* 属性 + vanilla JS
React の useState + useEffect を以下のように置き換え:
Before(React):
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (isOpen) document.body.style.overflow = 'hidden';
}, [isOpen]);
After(Astro + vanilla JS):
<button id="sp-menu-toggle" aria-expanded="false">...</button>
<nav id="sp-menu-overlay" class="hidden" />
<script>
const toggle = document.getElementById('sp-menu-toggle');
const overlay = document.getElementById('sp-menu-overlay');
let isOpen = false;
toggle.addEventListener('click', () => {
isOpen = !isOpen;
if (isOpen) {
overlay.classList.remove('hidden');
overlay.classList.add('flex');
document.body.style.overflow = 'hidden';
} else {
overlay.classList.add('hidden');
document.body.style.overflow = '';
}
});
</script>
Key points:
- Props を
data-*属性で受け渡し(const formId = container.dataset.formId) - クロージャで状態管理(モジュールスコープの
let変数) document.addEventListener('astro:after-swap', initFunction)で View Transitions 対応
パターン2: React hooks → vanilla JS(body scroll lock, IntersectionObserver)
useBodyScrollLock の置き換え:
function lockScroll(): void {
document.documentElement.style.overflowY = 'hidden';
document.body.style.overflowY = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
}
function unlockScroll(): void {
document.documentElement.style.overflowY = '';
document.body.style.overflowY = '';
document.body.style.position = '';
document.body.style.width = '';
}
IntersectionObserver の直接使用:
// 追従CTA: FV要素が画面外に出たら表示
const container = document.getElementById('dejam-following-cta');
const target = document.getElementById('first-view');
window.addEventListener('scroll', () => {
const { bottom } = target.getBoundingClientRect();
if (bottom < 0) {
container.classList.remove('hidden');
container.classList.add('flex');
} else {
container.classList.add('hidden');
container.classList.remove('flex');
}
}, { passive: true });
パターン3: embla-carousel-react → vanilla embla-carousel
useEmblaCarousel の廃止:
<div class="carousel">
<div class="carousel__viewport">
<ul class="carousel__container">
{imgs.map((src) => <li><img src={src} /></li>)}
</ul>
</div>
<ul class="carousel__dots">
{imgs.map((_, i) => <li class="dot" data-index={i} />)}
</ul>
</div>
<script>
import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';
function initCarousel(root) {
const viewport = root.querySelector('.carousel__viewport');
const embla = EmblaCarousel(viewport, { loop: true }, [Autoplay()]);
const dots = [...root.querySelectorAll('.dot')];
function updateDots() {
const selected = embla.selectedScrollSnap();
dots.forEach((dot, i) => {
dot.classList.toggle('bg-primary', i === selected);
});
}
embla.on('select', updateDots);
dots.forEach((dot) => {
dot.addEventListener('click', () => {
const index = Number(dot.dataset.index);
embla.scrollTo(index);
});
});
}
document.querySelectorAll('.carousel').forEach(initCarousel);
</script>
Key points:
EmblaCarousel(viewport, options, plugins)で直接初期化embla.on('select', callback)でイベントリスナー登録embla.scrollTo(index)/scrollPrev()/scrollNext()で操作
パターン4: 複雑な UI 状態管理(PC/SP ヘッダー)
Zustand store → クロージャ変数:
function initDejamHeaderPC() {
const wrapper = document.querySelector('[data-dejam-header-pc]');
const dropdownPanels = wrapper.querySelectorAll('[data-dropdown-panel]');
const navTriggers = wrapper.querySelectorAll('[data-nav-trigger]');
let activeDropdownId = ''; // ← クロージャで状態管理
function showDropdown(id) {
activeDropdownId = id;
dropdownPanels.forEach((panel) => {
if (panel.dataset.dropdownPanel === id) {
panel.classList.remove('hidden');
} else {
panel.classList.add('hidden');
}
});
updateNavStyles();
}
function closeDropdown() {
activeDropdownId = '';
dropdownPanels.forEach((p) => p.classList.add('hidden'));
updateNavStyles();
}
// mouseenter / mouseleave リスナー
navTriggers.forEach((trigger) => {
trigger.addEventListener('mouseenter', () => {
showDropdown(trigger.dataset.navTrigger);
});
});
wrapper.addEventListener('mouseleave', closeDropdown);
}
initDejamHeaderPC();
document.addEventListener('astro:after-swap', initDejamHeaderPC);
SP メニューのネストされたアコーディオン:
// 各ドロップダウントリガーをループで登録
dropdownTriggers.forEach((trigger) => {
trigger.addEventListener('click', () => {
const dropdownId = trigger.dataset.spDropdownTrigger;
const content = root.querySelector(`[data-sp-dropdown-content="${dropdownId}"]`);
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
content.classList.add('hidden');
trigger.setAttribute('aria-expanded', 'false');
icon.src = '/images/icon/icon_plus_primary.svg';
} else {
content.classList.remove('hidden');
trigger.setAttribute('aria-expanded', 'true');
icon.src = '/images/icon/icon_minus_primary.svg';
}
});
});
パターン5: react-hook-form → Svelte 5 Runes
React フォーム複雑化時は Svelte 5 を採用。理由:Runes による洗練された状態管理。
Svelte 5 の $state / $derived:
<script>
import { z } from 'zod';
// Props
const { turnstileSiteKey } = $props();
// State
let companyName = $state('');
let email = $state('');
let fieldErrors = $state({});
let isSubmitting = $state(false);
// Derived(自動再計算)
const formData = $derived({
companyName,
email,
// ...
});
const isButtonDisabled = $derived(
isSubmitting || !turnstileToken || !termCheckbox
);
// Validation(immutable pattern)
function validateField(fieldId) {
const result = contactSchema.safeParse(formData);
if (result.success) {
const { [fieldId]: _, ...rest } = fieldErrors;
fieldErrors = rest; // 新オブジェクト生成
return;
}
fieldErrors = { ...fieldErrors, [fieldId]: issue.message };
}
// Event handlers
function handleInput(fieldId, event) {
const target = event.target;
switch (fieldId) {
case 'companyName': companyName = target.value; break;
case 'email': email = target.value; break;
}
if (touched[fieldId]) validateField(fieldId);
}
async function handleSubmit(event) {
event.preventDefault();
if (!validateAllFields()) return;
isSubmitting = true;
try {
await sendContact({ companyName, email }, turnstileToken);
window.location.href = '/complete/';
} finally {
isSubmitting = false;
}
}
</script>
<form onsubmit={handleSubmit}>
{#each FORM_FIELDS as field}
<input
value={companyName}
oninput={(e) => handleInput('companyName', e)}
onblur={() => handleBlur('companyName')}
/>
{#if fieldErrors.companyName}
<p>{fieldErrors.companyName}</p>
{/if}
{/each}
<button disabled={isButtonDisabled} type="submit">送信</button>
</form>
Why Svelte for forms:
$state+$derivedで React のuseState+useMemoを1行で表現oninput/onchangeで直接バインディング(event delegation 不要)- Zod スキーマとの統合が簡潔
共通の工夫ポイント
1. astro:after-swap 対応(View Transitions)
function init() { /* 初期化処理 */ }
init();
document.addEventListener('astro:after-swap', init);
ClientRouter でページ遷移時も再初期化が必要。
2. esbuild パースエラー回避
フロントマター内で HTML タグを使わない:
---
// これは NG(dev 時 esbuild がパースエラー)
/**
* <script>alert('xss')</script>
*/
// これは OK(// コメント使用)
// - ドロップダウン表示
// - hover イベント管理
---
3. data-* 属性で Props 受け渡し
<div data-carousel data-autoplay="true" data-loop="false">
...
</div>
<script>
const carousel = document.querySelector('[data-carousel]');
const autoplay = carousel.dataset.autoplay === 'true';
const loop = carousel.dataset.loop === 'true';
</script>
4. client: ディレクティブ不要
vanilla JS は自動的にクライアント実行。<script is:inline> で SSR をスキップ可能。
削除した npm パッケージ
react 19
react-dom 19
@react-aria/*(17 dependency)
embla-carousel-react → embla-carousel に統一
zustand(2つの store のみ利用、残すのは検討中)
バンドルサイズ削減:
- Before: ~180KB (gzipped)
- After: ~125KB (gzipped)
- 削減: 30%
実装チェックリスト
各コンポーネント移植時:
- data-* 属性で Props 受け渡し
-
astro:after-swapイベントリスナー登録 - TypeScript 型定義(
HTMLElementなど) - null guard で TS narrowing(クロージャ内で確実に)
-
classListで class 切り替え(immutable pattern) - aria-* 属性で a11y 対応
-
{ passive: true }で scroll リスナー最適化
まとめ
| React | vanilla JS / Svelte |
|---|---|
useState | クロージャ変数 / $state |
useEffect | addEventListener / onMount |
useMemo | $derived |
| Props | data-* 属性 + dataset |
| Store(Zustand) | クロージャ変数 |
| Hook(embla) | 直接 SDK 呼び出し |
| Event delegation | 直接 addEventListener |
結論: React を脱却することで、ビルド時間短縮・バンドルサイズ削減・保守性向上を同時実現。Astro + vanilla JS は小規模な UI インタラクションに最適。複雑な state 管理が必要な場合のみ Svelte 5 を検討。