· Nacho Coll · Tutorials  · 14 分で読了

IPFS + React:分散型ファイルアップロードコンポーネントの構築

ファイルをIPFSに固定するReactのドラッグアンドドロップファイルアップロードコンポーネントを構築。安全なブラウザアップロードのための署名トークンを使った完全チュートリアル。

ファイルをIPFSに固定するReactのドラッグアンドドロップファイルアップロードコンポーネントを構築。安全なブラウザアップロードのための署名トークンを使った完全チュートリアル。

ReactとIPFSで分散型アプリケーションを構築することがこれほど簡単になったことはありません。この包括的なチュートリアルでは、ブラウザから直接IPFSにファイルを固定する本番環境対応のファイルアップロードコンポーネントの作成方法を学びます。IPFS.NINJAの署名トークンを使用して、フロントエンドコードでAPIキーを公開することなく安全なアップロードを保証します。

このガイドの最後には、アップロードの進行状況、エラー処理、TanStack Queryによる適切な状態管理を備えた完全に機能するドラッグアンドドロップコンポーネントが完成しています。

IPFS Ninja

なぜReact IPFSアップロードコンポーネントを構築するのか?

AWS S3のような従来のクラウドストレージサービスは、データを中央集権化し、ベンダーロックインを生み出します。IPFS(InterPlanetary File System)を使用すると、ファイルはピアツーピアネットワーク全体に分散され、より回復力があり検閲耐性があります。

このアプローチの主な利点:

  • 分散型ストレージ:ファイルが複数のノードに分散
  • コンテンツアドレッシング:各ファイルは一意のCID(コンテンツ識別子)を取得
  • 不変リンク:アップロードされると、CIDを変更せずにファイルを変更することはできません
  • グローバルアクセシビリティ:任意のIPFSゲートウェイからファイルにアクセス可能
  • 安全なアップロード:APIキーを公開する代わりに署名トークンを使用

IPFSの概念に慣れていない場合は、コードに飛び込む前にIPFSピン留めとは何かに関するガイドをご覧ください。

前提条件

始める前に、以下を確認してください:

  • 基本的なReactの知識
  • Node.js 16+がインストール済み
  • IPFS.NINJAアカウント(こちらからサインアップ
  • モダンなReactパターン(hooks、async/await)の理解

プロジェクトのセットアップ

まず、新しいReactプロジェクトを作成し、必要な依存関係をインストールしましょう:

npx create-react-app ipfs-upload-demo
cd ipfs-upload-demo
npm install @tanstack/react-query react-dropzone lucide-react

これらのライブラリを使用します:

  • @tanstack/react-query:状態管理とキャッシング用
  • react-dropzone:ドラッグアンドドロップ機能用
  • lucide-react:アイコン用

署名トークンの理解

コンポーネントを構築する前に、APIキーの代わりに署名トークンを使用する理由を理解することが重要です:

// ❌ これは絶対にしない - APIキーを公開してしまう
const response = await fetch('https://api.ipfs.ninja/upload/new', {
  headers: {
    'X-Api-Key': 'bws_your_secret_key_here' // これはブラウザで見える!
  }
});

// ✅ 代わりに署名トークンを使用
const response = await fetch('https://api.ipfs.ninja/upload/new', {
  headers: {
    'Authorization': `Signed ${signedToken}` // クライアント側で使用しても安全
  }
});

署名トークンは、サーバー側で生成しフロントエンドに渡す時間制限付きの認証情報です。アカウント設定やその他の機密操作にアクセスするために使用することはできません。

バックエンドトークンエンドポイントの作成

まず、署名トークンを生成するシンプルなバックエンドエンドポイントを作成します。これはNext.js APIルート、Expressエンドポイント、またはサーバーレス関数にできます:

// api/upload-token.js (Next.js APIルートの例)
export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  try {
    const response = await fetch('https://api.ipfs.ninja/upload/tokens', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Api-Key': process.env.IPFS_NINJA_API_KEY // サーバー側のみ!
      },
      body: JSON.stringify({
        expiresIn: '1h',
        maxUploads: 10,
        description: 'React upload component token'
      })
    });

    const data = await response.json();
    
    if (!response.ok) {
      throw new Error(data.error || 'Token generation failed');
    }

    res.json({ token: data.token });
  } catch (error) {
    console.error('Token generation error:', error);
    res.status(500).json({ error: 'Failed to generate upload token' });
  }
}

アップロードフックの構築

TanStack Queryを使用してアップロードロジックを管理するカスタムフックを作成しましょう:

// hooks/useIPFSUpload.js
import { useMutation, useQueryClient } from '@tanstack/react-query';

const uploadToIPFS = async ({ file, token }) => {
  // JSONアップロード用にファイルをbase64に変換
  const base64Content = await new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result.split(',')[1]);
    reader.readAsDataURL(file);
  });

  const response = await fetch('https://api.ipfs.ninja/upload/new', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Signed ${token}`
    },
    body: JSON.stringify({
      content: base64Content,
      description: `Uploaded via React: ${file.name}`,
      metadata: {
        originalName: file.name,
        size: file.size,
        type: file.type,
        uploadedAt: new Date().toISOString()
      }
    })
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error || 'Upload failed');
  }

  const data = await response.json();
  return {
    ...data,
    originalFile: {
      name: file.name,
      size: file.size,
      type: file.type
    }
  };
};

export const useIPFSUpload = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: uploadToIPFS,
    onSuccess: (data) => {
      // アップロードされたファイルデータをキャッシュ
      queryClient.setQueryData(['uploads'], (oldData = []) => [
        ...oldData,
        data
      ]);
    }
  });
};

アップロードトークンの取得

バックエンドから署名トークンを取得する別のフックを作成します:

// hooks/useUploadToken.js
import { useQuery } from '@tanstack/react-query';

const fetchUploadToken = async () => {
  const response = await fetch('/api/upload-token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    }
  });

  if (!response.ok) {
    throw new Error('Failed to fetch upload token');
  }

  const data = await response.json();
  return data.token;
};

export const useUploadToken = () => {
  return useQuery({
    queryKey: ['upload-token'],
    queryFn: fetchUploadToken,
    staleTime: 30 * 60 * 1000, // 30分
    retry: 3
  });
};

アップロードコンポーネントの作成

ここで、ドラッグアンドドロップ機能を持つメインのアップロードコンポーネントを構築しましょう:

// components/IPFSUploader.jsx
import React, { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { Upload, CheckCircle, XCircle, Loader2, Copy, ExternalLink } from 'lucide-react';
import { useIPFSUpload } from '../hooks/useIPFSUpload';
import { useUploadToken } from '../hooks/useUploadToken';

const IPFSUploader = () => {
  const [uploadedFiles, setUploadedFiles] = useState([]);
  const uploadMutation = useIPFSUpload();
  const { data: token, isLoading: isTokenLoading, error: tokenError } = useUploadToken();

  const onDrop = useCallback(async (acceptedFiles) => {
    if (!token) {
      console.error('No upload token available');
      return;
    }

    for (const file of acceptedFiles) {
      try {
        const result = await uploadMutation.mutateAsync({ file, token });
        setUploadedFiles(prev => [...prev, result]);
      } catch (error) {
        console.error('Upload failed:', error);
      }
    }
  }, [token, uploadMutation]);

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    maxSize: 10 * 1024 * 1024, // 10MB制限
    multiple: true
  });

  const copyToClipboard = (text) => {
    navigator.clipboard.writeText(text);
  };

  const formatFileSize = (bytes) => {
    if (bytes === 0) return '0 Bytes';
    const k = 1024;
    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  };

  if (isTokenLoading) {
    return (
      <div className="flex items-center justify-center p-8">
        <Loader2 className="w-6 h-6 animate-spin" />
        <span className="ml-2">Initializing uploader...</span>
      </div>
    );
  }

  if (tokenError) {
    return (
      <div className="p-4 bg-red-50 border border-red-200 rounded-lg">
        <div className="flex items-center">
          <XCircle className="w-5 h-5 text-red-500" />
          <span className="ml-2 text-red-700">Failed to initialize uploader</span>
        </div>
      </div>
    );
  }

  return (
    <div className="max-w-2xl mx-auto p-6">
      {/* アップロードドロップゾーン */}
      <div
        {...getRootProps()}
        className={`
          border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
          ${isDragActive 
            ? 'border-blue-400 bg-blue-50' 
            : 'border-gray-300 hover:border-gray-400'
          }
        `}
      >
        <input {...getInputProps()} />
        <Upload className="w-12 h-12 mx-auto text-gray-400 mb-4" />
        {isDragActive ? (
          <p className="text-blue-600">Drop the files here...</p>
        ) : (
          <div>
            <p className="text-gray-600 mb-2">
              Drag & drop files here, or click to select
            </p>
            <p className="text-sm text-gray-500">
              Max file size: 10MB
            </p>
          </div>
        )}
      </div>

      {/* アップロード進行状況 */}
      {uploadMutation.isPending && (
        <div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
          <div className="flex items-center">
            <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
            <span className="ml-2 text-blue-700">Uploading to IPFS...</span>
          </div>
        </div>
      )}

      {/* アップロードエラー */}
      {uploadMutation.isError && (
        <div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
          <div className="flex items-center">
            <XCircle className="w-5 h-5 text-red-500" />
            <span className="ml-2 text-red-700">
              {uploadMutation.error?.message || 'Upload failed'}
            </span>
          </div>
        </div>
      )}

      {/* アップロード済みファイルリスト */}
      {uploadedFiles.length > 0 && (
        <div className="mt-6">
          <h3 className="text-lg font-semibold mb-4">Uploaded Files</h3>
          <div className="space-y-3">
            {uploadedFiles.map((file, index) => (
              <div key={index} className="p-4 bg-green-50 border border-green-200 rounded-lg">
                <div className="flex items-start justify-between">
                  <div className="flex items-start space-x-3">
                    <CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
                    <div>
                      <p className="font-medium text-gray-900">
                        {file.originalFile.name}
                      </p>
                      <p className="text-sm text-gray-500">
                        {formatFileSize(file.originalFile.size)} • {file.sizeMB}MB on IPFS
                      </p>
                      <div className="mt-2">
                        <p className="text-xs text-gray-600">CID:</p>
                        <div className="flex items-center space-x-2 mt-1">
                          <code className="text-xs bg-gray-100 px-2 py-1 rounded">
                            {file.cid}
                          </code>
                          <button
                            onClick={() => copyToClipboard(file.cid)}
                            className="text-gray-400 hover:text-gray-600"
                          >
                            <Copy className="w-4 h-4" />
                          </button>
                        </div>
                      </div>
                    </div>
                  </div>
                  <a
                    href={file.uris.url}
                    target="_blank"
                    rel="noopener noreferrer"
                    className="text-blue-500 hover:text-blue-700"
                  >
                    <ExternalLink className="w-5 h-5" />
                  </a>
                </div>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
};

export default IPFSUploader;

Appコンポーネントのセットアップ

最後に、アプリをTanStack Queryプロバイダーでラップします:

// App.js
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import IPFSUploader from './components/IPFSUploader';
import './App.css';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 1,
      staleTime: 5 * 60 * 1000, // 5分
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div className="App">
        <header className="bg-white shadow-sm">
          <div className="max-w-4xl mx-auto px-4 py-6">
            <h1 className="text-3xl font-bold text-gray-900">
              IPFS File Uploader
            </h1>
            <p className="text-gray-600 mt-2">
              Upload files to IPFS with drag-and-drop simplicity
            </p>
          </div>
        </header>
        <main className="max-w-4xl mx-auto px-4 py-8">
          <IPFSUploader />
        </main>
      </div>
    </QueryClientProvider>
  );
}

export default App;

CSSスタイルの追加

コンポーネントを洗練された見た目にする基本的なスタイルを追加します:

/* App.css */
.App {
  min-height: 100vh;
  background-color: #f9fafb;
}

/* 適切なファイル入力スタイルを保証 */
input[type="file"] {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

高度な機能と最適化

ファイルタイプ検証

ファイルタイプ制限でドロップゾーンを強化:

const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
  onDrop,
  maxSize: 10 * 1024 * 1024,
  accept: {
    'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp'],
    'application/pdf': ['.pdf'],
    'text/plain': ['.txt'],
  },
  multiple: true
});

// ファイル拒否エラーを表示
{fileRejections.length > 0 && (
  <div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
    <p className="text-yellow-800 font-medium">Some files were rejected:</p>
    <ul className="mt-2 text-sm text-yellow-700">
      {fileRejections.map(({ file, errors }) => (
        <li key={file.path}>
          {file.path}: {errors.map(e => e.message).join(', ')}
        </li>
      ))}
    </ul>
  </div>
)}

アップロード進行状況のトラッキング

より良いユーザーエクスペリエンスのために、個々のファイルのアップロード進行状況を追跡したい場合があります。IPFS.NINJA APIは直接的に進行状況コールバックをサポートしませんが、進行状況をシミュレートしたりファイルごとのアップロード状態を表示したりできます:

const [uploadingFiles, setUploadingFiles] = useState(new Set());

const onDrop = useCallback(async (acceptedFiles) => {
  if (!token) return;

  for (const file of acceptedFiles) {
    const fileId = `${file.name}-${file.size}`;
    setUploadingFiles(prev => new Set([...prev, fileId]));

    try {
      const result = await uploadMutation.mutateAsync({ file, token });
      setUploadedFiles(prev => [...prev, result]);
    } catch (error) {
      console.error('Upload failed:', error);
    } finally {
      setUploadingFiles(prev => {
        const next = new Set(prev);
        next.delete(fileId);
        return next;
      });
    }
  }
}, [token, uploadMutation]);

エラー処理のベストプラクティス

本番環境対応コンポーネントのための包括的なエラー処理を実装:

const getErrorMessage = (error) => {
  if (error.message.includes('401')) {
    return 'Upload token expired. Please refresh the page.';
  }
  if (error.message.includes('413')) {
    return 'File too large. Maximum size is 10MB.';
  }
  if (error.message.includes('429')) {
    return 'Too many uploads. Please wait a moment and try again.';
  }
  return error.message || 'Upload failed. Please try again.';
};

コンポーネントのテスト

アップロード機能をテストするには:

  1. 開発サーバーを起動:npm start
  2. アップロードエリアにいくつかのファイルをドラッグ
  3. ファイルがアップロードされCIDが表示されることを確認
  4. 外部リンクアイコンをクリックしてIPFS URLが機能することを確認
  5. エラーシナリオをテスト(大きなファイル、ネットワークの問題など)

デプロイメントの考慮事項

React IPFSアップローダーをデプロイするとき:

環境変数

IPFS.NINJA APIキーを安全に保存:

# .env.local(Next.js用)
IPFS_NINJA_API_KEY=bws_your_api_key_here

トークン更新戦略

長時間実行セッションのために自動トークン更新を実装:

export const useUploadToken = () => {
  return useQuery({
    queryKey: ['upload-token'],
    queryFn: fetchUploadToken,
    staleTime: 25 * 60 * 1000, // 25分(トークンは30分で有効期限切れ)
    refetchOnWindowFocus: true,
    retry: 3
  });
};

CORS構成

バックエンドがフロントエンドドメインからのリクエストを許可していることを確認します。

パフォーマンス最適化

大きなファイルや複数のアップロードでのより良いパフォーマンスのために:

  1. Web Workersを使用してbase64変換を行う
  2. アップロードキューを実装して同時アップロードを制限
  3. 画像圧縮を追加してアップロード前に画像を圧縮
  4. アップロードトークンを適切にキャッシュして不要なリクエストを回避

他のソリューションとの比較

IPFS.NINJAを使用した私たちのアプローチには、代替案と比較していくつかの利点があります。異なるIPFSピン留めサービスを評価している場合は、IPFS.NINJA vs Pinataでの包括的な比較と、最高のIPFSピン留めサービスに関するガイドをご覧ください。

IPFSアップロード方法のより広い理解のために、IPFS upload APIチュートリアルIPFSにファイルをアップロードする方法ガイドをご覧ください。

次のステップ

これで、完全に機能するReact IPFSアップロードコンポーネントが完成しました!拡張する方法をいくつか紹介します:

  • ファイルプレビュー機能を追加
  • バッチ操作(削除、共有)を実装
  • アップロード分析とメトリクスを作成
  • アプリの認証システムとの統合を追加
  • フォルダ整理機能を構築
  • ファイル検索とフィルタリングを実装

Reactのコンポーネントアーキテクチャと IPFSの分散ストレージの組み合わせは、レジリエントでユーザーフレンドリーなアプリケーションを構築する強力な可能性を生み出します。

ピン留めを開始する準備はできましたか? 無料アカウントを作成 — 50ファイル、1GBストレージ、月2GBの帯域幅。クレジットカードは不要です。

ブログに戻る
IPFS Upload API — 完全な開発者チュートリアル

IPFS Upload API — 完全な開発者チュートリアル

REST API 経由で IPFS にファイルをアップロードする方法を学びます。JavaScript、Node.js、curl の完全なコード例。JSON、画像のアップロード、クライアントサイドアップロード用の署名付きトークンの使用。