React統合ガイド
DuckDB WASMアダプターをReactアプリケーションで使用するための包括的なガイドです。
セットアップ
インストール
bash
npm install @northprint/duckdb-wasm-adapter-react基本設定
tsx
// App.tsx
import React from 'react';
import { DuckDBProvider } from '@northprint/duckdb-wasm-adapter-react';
function App() {
return (
<DuckDBProvider
autoConnect={true}
config={{
worker: true,
logLevel: 'warning'
}}
>
<YourApplication />
</DuckDBProvider>
);
}
export default App;主要なフック
useQuery - データ取得
最も基本的で重要なフックです。SQLクエリを実行し、結果をReactコンポーネントで使用できます。
tsx
import { useQuery } from '@northprint/duckdb-wasm-adapter-react';
function UserList() {
const { data, loading, error, refetch } = useQuery(
'SELECT * FROM users ORDER BY name',
undefined, // パラメーター
{
cacheTime: 60000, // 1分間キャッシュ
enabled: true // 自動実行
}
);
if (loading) return <div>読み込み中...</div>;
if (error) return <div>エラー: {error.message}</div>;
return (
<div>
<button onClick={refetch}>更新</button>
<ul>
{data?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}useMutation - データ変更
INSERT、UPDATE、DELETE操作を実行するためのフックです。
tsx
import { useMutation } from '@northprint/duckdb-wasm-adapter-react';
function AddUserForm() {
const { mutate, loading, error } = useMutation({
onSuccess: (data) => {
console.log('ユーザー追加成功:', data);
// フォームをリセット
},
onError: (error) => {
console.error('エラー:', error);
}
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
await mutate(
'INSERT INTO users (name, email, age) VALUES (?, ?, ?)',
[
formData.get('name'),
formData.get('email'),
Number(formData.get('age'))
]
);
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="名前" required />
<input name="email" type="email" placeholder="メール" required />
<input name="age" type="number" placeholder="年齢" required />
<button type="submit" disabled={loading}>
{loading ? '追加中...' : '追加'}
</button>
{error && <p className="error">{error.message}</p>}
</form>
);
}実践的なパターン
データテーブルコンポーネント
tsx
import React, { useState } from 'react';
import { useQuery } from '@northprint/duckdb-wasm-adapter-react';
interface DataTableProps {
tableName: string;
columns: Array<{
key: string;
label: string;
sortable?: boolean;
}>;
}
function DataTable({ tableName, columns }: DataTableProps) {
const [sortColumn, setSortColumn] = useState<string>('');
const [sortDirection, setSortDirection] = useState<'ASC' | 'DESC'>('ASC');
const [page, setPage] = useState(1);
const perPage = 10;
// 動的クエリの構築
const query = React.useMemo(() => {
let sql = `SELECT * FROM ${tableName}`;
if (sortColumn) {
sql += ` ORDER BY ${sortColumn} ${sortDirection}`;
}
sql += ` LIMIT ${perPage} OFFSET ${(page - 1) * perPage}`;
return sql;
}, [tableName, sortColumn, sortDirection, page]);
const { data, loading, error } = useQuery(query);
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDirection(prev => prev === 'ASC' ? 'DESC' : 'ASC');
} else {
setSortColumn(column);
setSortDirection('ASC');
}
};
if (loading) return <div>読み込み中...</div>;
if (error) return <div>エラー: {error.message}</div>;
return (
<div>
<table>
<thead>
<tr>
{columns.map(column => (
<th
key={column.key}
onClick={() => column.sortable && handleSort(column.key)}
style={{ cursor: column.sortable ? 'pointer' : 'default' }}
>
{column.label}
{sortColumn === column.key && (
<span>{sortDirection === 'ASC' ? ' ▲' : ' ▼'}</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{data?.map((row, index) => (
<tr key={index}>
{columns.map(column => (
<td key={column.key}>{row[column.key]}</td>
))}
</tr>
))}
</tbody>
</table>
<div className="pagination">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
前へ
</button>
<span>ページ {page}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={!data || data.length < perPage}
>
次へ
</button>
</div>
</div>
);
}検索フォーム
tsx
import React, { useState, useCallback, useEffect } from 'react';
import { useQuery } from '@northprint/duckdb-wasm-adapter-react';
import { debounce } from 'lodash';
function SearchForm() {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedTerm, setDebouncedTerm] = useState('');
// デバウンス処理
const debouncedSearch = useCallback(
debounce((term: string) => {
setDebouncedTerm(term);
}, 300),
[]
);
useEffect(() => {
debouncedSearch(searchTerm);
}, [searchTerm, debouncedSearch]);
// 検索クエリ
const { data, loading } = useQuery(
debouncedTerm
? `SELECT * FROM products WHERE name LIKE ? OR description LIKE ?`
: 'SELECT * FROM products LIMIT 10',
debouncedTerm
? [`%${debouncedTerm}%`, `%${debouncedTerm}%`]
: undefined
);
return (
<div>
<input
type="text"
placeholder="商品を検索..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{loading && <div>検索中...</div>}
<div className="results">
{data?.map(product => (
<div key={product.id} className="product">
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>¥{product.price}</span>
</div>
))}
</div>
</div>
);
}ダッシュボード
tsx
import React from 'react';
import { useQuery } from '@northprint/duckdb-wasm-adapter-react';
function Dashboard() {
// 複数のクエリを並列実行
const { data: salesData } = useQuery(`
SELECT
DATE(order_date) as date,
SUM(total) as total_sales
FROM orders
WHERE order_date >= DATE('now', '-30 days')
GROUP BY DATE(order_date)
ORDER BY date
`);
const { data: topProducts } = useQuery(`
SELECT
p.name,
COUNT(oi.id) as order_count,
SUM(oi.quantity) as total_quantity
FROM order_items oi
JOIN products p ON oi.product_id = p.id
GROUP BY p.id, p.name
ORDER BY total_quantity DESC
LIMIT 5
`);
const { data: customerStats } = useQuery(`
SELECT
COUNT(DISTINCT customer_id) as total_customers,
AVG(total) as avg_order_value,
MAX(total) as max_order_value
FROM orders
`);
return (
<div className="dashboard">
<div className="stats-row">
{customerStats && (
<>
<StatCard
title="顧客数"
value={customerStats[0].total_customers}
/>
<StatCard
title="平均注文額"
value={`¥${customerStats[0].avg_order_value.toFixed(0)}`}
/>
<StatCard
title="最高注文額"
value={`¥${customerStats[0].max_order_value}`}
/>
</>
)}
</div>
<div className="charts">
<SalesChart data={salesData} />
<TopProductsChart data={topProducts} />
</div>
</div>
);
}
function StatCard({ title, value }: { title: string; value: any }) {
return (
<div className="stat-card">
<h3>{title}</h3>
<p className="value">{value}</p>
</div>
);
}カスタムフック
useUserData - ユーザー固有のデータフック
tsx
import { useQuery } from '@northprint/duckdb-wasm-adapter-react';
export function useUserData(userId: number) {
const { data: user, ...userQuery } = useQuery(
'SELECT * FROM users WHERE id = ?',
[userId],
{ enabled: userId > 0 }
);
const { data: orders, ...ordersQuery } = useQuery(
'SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC',
[userId],
{ enabled: userId > 0 }
);
const { data: stats, ...statsQuery } = useQuery(
`SELECT
COUNT(*) as total_orders,
SUM(total) as total_spent,
AVG(total) as avg_order_value
FROM orders WHERE user_id = ?`,
[userId],
{ enabled: userId > 0 }
);
return {
user: user?.[0],
orders,
stats: stats?.[0],
loading: userQuery.loading || ordersQuery.loading || statsQuery.loading,
error: userQuery.error || ordersQuery.error || statsQuery.error
};
}usePagination - ページネーションフック
tsx
import { useState, useMemo } from 'react';
import { useQuery } from '@northprint/duckdb-wasm-adapter-react';
interface UsePaginationOptions {
table: string;
perPage?: number;
where?: string;
orderBy?: string;
}
export function usePagination({
table,
perPage = 10,
where = '',
orderBy = ''
}: UsePaginationOptions) {
const [page, setPage] = useState(1);
// 全件数を取得
const { data: countData } = useQuery(
`SELECT COUNT(*) as total FROM ${table} ${where}`
);
const totalCount = countData?.[0]?.total || 0;
const totalPages = Math.ceil(totalCount / perPage);
// ページデータを取得
const query = useMemo(() => {
let sql = `SELECT * FROM ${table}`;
if (where) sql += ` ${where}`;
if (orderBy) sql += ` ${orderBy}`;
sql += ` LIMIT ${perPage} OFFSET ${(page - 1) * perPage}`;
return sql;
}, [table, where, orderBy, perPage, page]);
const { data, loading, error, refetch } = useQuery(query);
return {
data,
loading,
error,
page,
totalPages,
totalCount,
hasNext: page < totalPages,
hasPrev: page > 1,
nextPage: () => setPage(p => Math.min(p + 1, totalPages)),
prevPage: () => setPage(p => Math.max(p - 1, 1)),
goToPage: (p: number) => setPage(Math.max(1, Math.min(p, totalPages))),
refetch
};
}パフォーマンス最適化
React.memo によるメモ化
tsx
import React from 'react';
import { useQuery } from '@northprint/duckdb-wasm-adapter-react';
const ExpensiveComponent = React.memo(({ userId }: { userId: number }) => {
const { data } = useQuery(
'SELECT * FROM expensive_view WHERE user_id = ?',
[userId]
);
return (
<div>
{/* 重い描画処理 */}
</div>
);
}, (prevProps, nextProps) => {
// userIdが変わらない限り再描画しない
return prevProps.userId === nextProps.userId;
});useMemo と useCallback
tsx
function OptimizedList() {
const [filter, setFilter] = useState('');
// クエリをメモ化
const query = useMemo(() => {
if (!filter) return 'SELECT * FROM items';
return `SELECT * FROM items WHERE name LIKE '%${filter}%'`;
}, [filter]);
const { data } = useQuery(query);
// ハンドラーをメモ化
const handleFilterChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
}, []);
// 計算結果をメモ化
const totalValue = useMemo(() => {
return data?.reduce((sum, item) => sum + item.value, 0) || 0;
}, [data]);
return (
<div>
<input onChange={handleFilterChange} />
<div>合計: {totalValue}</div>
{/* リスト表示 */}
</div>
);
}エラーハンドリング
エラーバウンダリー
tsx
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert" className="error-fallback">
<h2>エラーが発生しました</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>再試行</button>
</div>
);
}
function App() {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => window.location.reload()}
>
<DuckDBProvider>
<YourApp />
</DuckDBProvider>
</ErrorBoundary>
);
}カスタムエラー処理
tsx
import { DuckDBError, ErrorCode } from '@northprint/duckdb-wasm-adapter-react';
function QueryComponent() {
const { data, error } = useQuery('SELECT * FROM users');
if (error) {
if (error instanceof DuckDBError) {
switch (error.code) {
case ErrorCode.CONNECTION_FAILED:
return <div>データベースに接続できません</div>;
case ErrorCode.QUERY_FAILED:
return <div>クエリの実行に失敗しました</div>;
case ErrorCode.TABLE_NOT_FOUND:
return <div>テーブルが見つかりません</div>;
default:
return <div>予期しないエラー: {error.message}</div>;
}
}
return <div>エラー: {error.message}</div>;
}
return <div>{/* データ表示 */}</div>;
}テスト
コンポーネントのテスト
tsx
import { render, screen, waitFor } from '@testing-library/react';
import { DuckDBProvider } from '@northprint/duckdb-wasm-adapter-react';
import UserList from './UserList';
// モックデータ
const mockConnection = {
execute: jest.fn().mockResolvedValue({
toArray: () => [
{ id: 1, name: 'テストユーザー1' },
{ id: 2, name: 'テストユーザー2' }
]
})
};
describe('UserList', () => {
it('ユーザーリストを表示する', async () => {
render(
<DuckDBProvider connection={mockConnection}>
<UserList />
</DuckDBProvider>
);
await waitFor(() => {
expect(screen.getByText('テストユーザー1')).toBeInTheDocument();
expect(screen.getByText('テストユーザー2')).toBeInTheDocument();
});
});
});カスタムフックのテスト
tsx
import { renderHook, waitFor } from '@testing-library/react';
import { useUserData } from './useUserData';
describe('useUserData', () => {
it('ユーザーデータを取得する', async () => {
const { result } = renderHook(() => useUserData(1));
await waitFor(() => {
expect(result.current.user).toBeDefined();
expect(result.current.orders).toBeDefined();
expect(result.current.stats).toBeDefined();
});
});
});Next.js統合
tsx
// pages/_app.tsx
import { DuckDBProvider } from '@northprint/duckdb-wasm-adapter-react';
function MyApp({ Component, pageProps }) {
return (
<DuckDBProvider
config={{
worker: true,
// Next.jsでWeb Workerを使用する設定
workerUrl: '/duckdb-worker.js'
}}
>
<Component {...pageProps} />
</DuckDBProvider>
);
}
export default MyApp;ベストプラクティス
- Provider は最上位に配置 - アプリケーション全体で接続を共有
- 適切なキャッシング - 頻繁に変更されないデータはキャッシュ
- エラーバウンダリーの使用 - 予期しないエラーをキャッチ
- メモ化の活用 - 不要な再レンダリングを防ぐ
- 型安全性 - TypeScriptで型を定義
- テストの作成 - 重要なロジックはテストでカバー
トラブルシューティング
Web Workerの問題
tsx
// Web Workerが使えない環境での対処
<DuckDBProvider
config={{
worker: false // メインスレッドで実行
}}
>メモリ不足
tsx
// 大きなデータセットを扱う場合
const { data } = useQuery(
'SELECT * FROM large_table LIMIT 1000', // 制限を設ける
undefined,
{
cacheTime: 0 // キャッシュを無効化
}
);