ReactからQwikへの移行ガイド — Resumabilityへの道


Reactからqwikへの移行は、ハイドレーションによるパフォーマンスボトルネックを解消する有力な選択肢です。

この記事では、既存のReactアプリをQwikに移行するための実践的なガイドを提供します。

なぜQwikに移行するのか

Reactの課題

ハイドレーション問題:

1. サーバーがHTMLを生成
2. ブラウザが静的HTMLを表示(操作不可)
3. JavaScriptをダウンロード(80KB以上)
4. ハイドレーション実行(全コンポーネント再構築)
5. やっとインタラクティブに(1-3秒後)

Qwikの利点

Resumability:

1. サーバーがHTML + イベントリスナー情報を生成
2. ブラウザが表示(即座にインタラクティブ)
3. ユーザーがクリック → その時だけJSダウンロード

実測パフォーマンス:

  • 初期JSバンドル: 80KB → 1KB以下
  • Time to Interactive: 1-3秒 → 即座
  • Lighthouse Score: 70-90 → 95-100

移行戦略

段階的移行 vs 全面リライト

段階的移行(推奨):

  1. 新しいページ/機能をQwikで実装
  2. Reactコンポーネントを徐々にQwikに変換
  3. 共存しながら移行

全面リライト:

  • 小規模アプリ(<10ページ)に適している
  • リスクは高いが、完全最適化が可能

移行の順序

1. 新規プロジェクト作成
2. ルーティング設定
3. レイアウトコンポーネント
4. 静的コンポーネント(Header、Footer)
5. 動的コンポーネント(フォーム、リスト)
6. 複雑な状態管理
7. API統合
8. 最適化

コンポーネントの変換

基本的な変換パターン

React:

// Button.tsx
import { FC } from 'react';

interface ButtonProps {
  label: string;
  onClick: () => void;
}

export const Button: FC<ButtonProps> = ({ label, onClick }) => {
  return (
    <button onClick={onClick} className="btn">
      {label}
    </button>
  );
};

Qwik:

// button.tsx
import { component$, type QwikClickEvent } from '@builder.io/qwik';

interface ButtonProps {
  label: string;
  onClick$: (event: QwikClickEvent<HTMLButtonElement>) => void;
}

export const Button = component$<ButtonProps>(({ label, onClick$ }) => {
  return (
    <button onClick$={onClick$} class="btn">
      {label}
    </button>
  );
});

主な変更点:

  • FCcomponent$()
  • onClickonClick$
  • classNameclass
  • $ サフィックスでイベントハンドラを遅延実行

useState の変換

React:

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  );
}

Qwik:

import { component$, useSignal } from '@builder.io/qwik';

export const Counter = component$(() => {
  const count = useSignal(0);

  return (
    <div>
      <p>Count: {count.value}</p>
      <button onClick$={() => count.value++}>
        Increment
      </button>
    </div>
  );
});

主な変更点:

  • useStateuseSignal
  • countcount.value
  • setCount(count + 1)count.value++

useEffect の変換

React:

import { useState, useEffect } from 'react';

export function DataFetcher() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(data => {
        setData(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <div>Loading...</div>;

  return <div>{JSON.stringify(data)}</div>;
}

Qwik:

import { component$, useSignal, useTask$ } from '@builder.io/qwik';

export const DataFetcher = component$(() => {
  const data = useSignal(null);
  const loading = useSignal(true);

  useTask$(async () => {
    const response = await fetch('/api/data');
    data.value = await response.json();
    loading.value = false;
  });

  if (loading.value) return <div>Loading...</div>;

  return <div>{JSON.stringify(data.value)}</div>;
});

主な変更点:

  • useEffectuseTask$
  • 依存配列不要(自動追跡)
  • async/await が直接使用可能

useEffect with dependencies の変換

React:

import { useState, useEffect } from 'react';

export function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);

  return <div>{user?.name}</div>;
}

Qwik:

import { component$, useSignal, useTask$ } from '@builder.io/qwik';

export const UserProfile = component$(({ userId }: { userId: string }) => {
  const user = useSignal(null);

  useTask$(({ track }) => {
    track(() => userId); // userIdの変更を追跡

    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => user.value = data);
  });

  return <div>{user.value?.name}</div>;
});

ルーティングの移行

React Router → Qwik City

React Router:

// App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Home } from './pages/Home';
import { About } from './pages/About';
import { BlogPost } from './pages/BlogPost';

export function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/blog/:slug" element={<BlogPost />} />
      </Routes>
    </BrowserRouter>
  );
}

Qwik City:

src/routes/
├── index.tsx              → /
├── about/
│   └── index.tsx          → /about
└── blog/
    └── [slug]/
        └── index.tsx      → /blog/:slug
// src/routes/blog/[slug]/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';

export const usePost = routeLoader$(async (requestEvent) => {
  const slug = requestEvent.params.slug;
  const response = await fetch(`/api/posts/${slug}`);
  return await response.json();
});

export default component$(() => {
  const post = usePost();

  return (
    <article>
      <h1>{post.value.title}</h1>
      <div>{post.value.content}</div>
    </article>
  );
});

Next.js → Qwik City

Next.js Pages Router:

// pages/posts/[id].tsx
import { GetServerSideProps } from 'next';

export default function Post({ post }: { post: any }) {
  return <div>{post.title}</div>;
}

export const getServerSideProps: GetServerSideProps = async ({ params }) => {
  const response = await fetch(`/api/posts/${params?.id}`);
  const post = await response.json();
  return { props: { post } };
};

Qwik City:

// src/routes/posts/[id]/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';

export const usePost = routeLoader$(async (requestEvent) => {
  const id = requestEvent.params.id;
  const response = await fetch(`/api/posts/${id}`);
  return await response.json();
});

export default component$(() => {
  const post = usePost();
  return <div>{post.value.title}</div>;
});

状態管理の移行

Context API → Qwik Context

React:

// ThemeContext.tsx
import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext<any>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export const useTheme = () => useContext(ThemeContext);

Qwik:

// theme-context.tsx
import { component$, createContextId, useContextProvider, useContext, useSignal, Slot } from '@builder.io/qwik';

export const ThemeContext = createContextId<{ theme: Signal<string> }>('theme');

export const ThemeProvider = component$(() => {
  const theme = useSignal('light');

  useContextProvider(ThemeContext, { theme });

  return <Slot />;
});

export const useTheme = () => useContext(ThemeContext);

Zustand → Qwik Store

Zustand:

import create from 'zustand';

interface TodoStore {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
}

export const useTodoStore = create<TodoStore>((set) => ({
  todos: [],
  addTodo: (text) =>
    set((state) => ({
      todos: [...state.todos, { id: Date.now().toString(), text, done: false }],
    })),
  toggleTodo: (id) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
      ),
    })),
}));

Qwik:

// todo-store.ts
import { createContextId } from '@builder.io/qwik';

interface Todo {
  id: string;
  text: string;
  done: boolean;
}

interface TodoStore {
  todos: Todo[];
}

export const TodoContext = createContextId<TodoStore>('todo');

// todo-provider.tsx
import { component$, useStore, useContextProvider, Slot } from '@builder.io/qwik';

export const TodoProvider = component$(() => {
  const store = useStore<TodoStore>({
    todos: [],
  });

  useContextProvider(TodoContext, store);

  return <Slot />;
});

// useTodo.ts
import { useContext, $ } from '@builder.io/qwik';

export const useTodo = () => {
  const store = useContext(TodoContext);

  const addTodo = $((text: string) => {
    store.todos.push({
      id: Date.now().toString(),
      text,
      done: false,
    });
  });

  const toggleTodo = $((id: string) => {
    const todo = store.todos.find((t) => t.id === id);
    if (todo) {
      todo.done = !todo.done;
    }
  });

  return { store, addTodo, toggleTodo };
};

フォーム処理の移行

React Hook Form → Qwik Form

React Hook Form:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

export function LoginForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
  });

  const onSubmit = async (data: any) => {
    await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(data),
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}

      <button type="submit">Login</button>
    </form>
  );
}

Qwik City Form:

import { component$ } from '@builder.io/qwik';
import { routeAction$, Form, z, zod$ } from '@builder.io/qwik-city';

export const useLoginAction = routeAction$(
  async (data) => {
    // サーバー側で実行
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(data),
    });

    return { success: true };
  },
  zod$({
    email: z.string().email(),
    password: z.string().min(8),
  })
);

export default component$(() => {
  const action = useLoginAction();

  return (
    <Form action={action}>
      <input name="email" type="email" required />
      {action.value?.fieldErrors?.email && (
        <span>{action.value.fieldErrors.email}</span>
      )}

      <input name="password" type="password" required />
      {action.value?.fieldErrors?.password && (
        <span>{action.value.fieldErrors.password}</span>
      )}

      <button type="submit">Login</button>
    </Form>
  );
});

エコシステムの対応

スタイリング

ReactQwik備考
CSS Modules✅ CSS Modules完全対応
Tailwind CSS✅ Tailwind CSS完全対応
styled-componentsQwik Styled推奨
Emotion未対応

UIライブラリ

ReactQwik備考
Material-UIQwik UIを使用
Chakra UI未対応
Headless UI✅ Qwik UI完全対応
Radix UI✅ Qwik UI完全対応

Reactコンポーネントの統合

// Qwik内でReactコンポーネントを使用
import { qwikify$ } from '@builder.io/qwik-react';
import { DatePicker } from 'react-datepicker';

export const QwikDatePicker = qwikify$(DatePicker);

// 使用例
<QwikDatePicker selected={date.value} onChange$={(d) => date.value = d} />

パフォーマンス比較

実測データ(中規模ECサイト)

指標ReactQwik改善
初期JSバンドル187KB1.2KB99.4%削減
Time to Interactive2.8秒0.1秒96%高速化
Lighthouse Score769829%向上
Core Web Vitals不合格合格

まとめ

ReactからQwikへの移行は、パフォーマンスを劇的に改善する有力な選択肢です。

移行のメリット:

  • 初期ロード時間の大幅短縮
  • Time to Interactiveの改善
  • SEOスコアの向上
  • Core Web Vitals合格

移行のポイント:

  • $ サフィックスの理解
  • useSignal / useStore の活用
  • Qwik Cityのファイルベースルーティング
  • プログレッシブエンハンスメント

段階的に移行することで、リスクを最小化しながらパフォーマンスを最大化できます。