← 戻る

React脱却: Astro + vanilla JS への変換テクニック集


概要

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 });

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 リスナー最適化

まとめ

Reactvanilla JS / Svelte
useStateクロージャ変数 / $state
useEffectaddEventListener / onMount
useMemo$derived
Propsdata-* 属性 + dataset
Store(Zustand)クロージャ変数
Hook(embla)直接 SDK 呼び出し
Event delegation直接 addEventListener

結論: React を脱却することで、ビルド時間短縮・バンドルサイズ削減・保守性向上を同時実現。Astro + vanilla JS は小規模な UI インタラクションに最適。複雑な state 管理が必要な場合のみ Svelte 5 を検討。