0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RemixでCSVをFormで投入する

Last updated at Posted at 2025-08-27

はい、承知いたしました。
明日にはこの問題が完全に解決できるよう、2つの実装パターンについて、本番で使える詳細なコードと解説を網羅した完全版を作成します。

これから示す2つのパターンのうち、パターンAがRemixの思想に最も合致しており、堅牢でメンテナンス性も高いため、強く推奨します。 パターンBは学習・比較のために参考にしてください。



## パターンA:サーバーサイドで全て処理する方式(推奨)

このパターンでは、クライアントはCSVファイルをサーバーに渡すだけです。パース、DB検証、データ整形といったロジックは全てサーバーサイドで完結させます。

### なぜこのパターンが推奨されるのか?

  • クライアントがシンプル: UIは表示とユーザー操作に集中できます。
  • ロジックの集約: 「CSVインポート」という機能に関するロジックがサーバーの1箇所にまとまり、見通しが良く、修正も容易です。
  • 堅牢なエラーハンドリング: サーバーがCSVの全情報を持つため、「何行目の、どのデータが、なぜダメなのか」という具体的で親切なエラーを返せます。
  • パフォーマンスとセキュリティ: 重い処理はサーバーに任せ、ブラウザは快適なままです。

### 全体フロー

  1. クライアント: ユーザーがファイルを選択します。
  2. クライアント: handleFileChangeが発火し、fetcher.submitでCSVファイルそのものをAPIルートにPOSTします。
  3. サーバー (APIルート): action関数がファイルを受け取ります。
  4. サーバー: CSVをパースし、内容をZodで検証します。
  5. サーバー: tagNameuserIdを全て抽出し、findManyでDBに一括問い合わせします。
  6. サーバー: DBのデータとCSVのデータを突き合わせ、最終的なデータ構造の配列を作成します。DBに存在しないデータがあれば、この時点でエラーを返します。
  7. サーバー: 完成したデータ配列をJSONとしてクライアントに返します。
  8. クライアント: fetcher.dataでデータを受け取り、プレビュー表示します。
  9. クライアント: ユーザーが「登録」ボタンを押すと、プレビューされたデータがページのメインactionに送信されます。

### Step 1: APIリソースルートの作成

CSV処理の心臓部です。

app/routes/api.parse-tags-users-csv.ts
import {
  type ActionFunctionArgs,
  json,
  unstable_parseMultipartFormData,
  unstable_createMemoryUploadHandler,
} from "@remix-run/node";
import { z } from "zod";
import { parse as parseCsv } from "papaparse";
import { db } from "~/utils/db.server"; // PrismaClientのインスタンス

// CSVの1行の形式を定義
const CsvRowSchema = z.object({
  tagName: z.string().min(1, "tagNameは必須です"),
  userId: z.string().min(1, "userIdは必須です"),
});

// このAPIがフロントに返す、処理済みのデータの型定義
// フロントエンドのコンポーネントからもimportして使う
export type ProcessedData = {
  tagId: string;
  tagName: string;
  userId: string;
  userName: string;
};

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await unstable_parseMultipartFormData(
    request,
    unstable_createMemoryUploadHandler()
  );

  const file = formData.get("csvFile") as File | null;
  if (!file) {
    return json({ ok: false, error: "ファイルが見つかりません" }, { status: 400 });
  }

  const csvText = await file.text();
  const result = parseCsv(csvText, { header: true, skipEmptyLines: true });

  // 1. ZodでCSVの「形式」をバリデーション
  const validation = CsvRowSchema.array().safeParse(result.data);
  if (!validation.success) {
    return json({ ok: false, error: "CSVの列名や形式が不正です。", details: validation.error.flatten() }, { status: 400 });
  }
  const csvData = validation.data;

  try {
    // 2. DB問い合わせの準備 (N+1問題を防ぐため、ID/Nameを全て集める)
    const tagNames = [...new Set(csvData.map((row) => row.tagName))];
    const userIds = [...new Set(csvData.map((row) => row.userId))];

    // 3. DBに一括問い合わせ
    const [tags, users] = await Promise.all([
      db.tag.findMany({ where: { name: { in: tagNames } }, select: { id: true, name: true } }),
      db.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } }),
    ]);

    // 4. 高速に検索できるようMapに変換
    const tagMap = new Map(tags.map((t) => [t.name, t.id]));
    const userMap = new Map(users.map((u) => [u.id, u.name]));

    // 5. CSVデータとDBデータをマージして最終データを作成
    const processedData: ProcessedData[] = [];
    for (const [index, row] of csvData.entries()) {
      const tagId = tagMap.get(row.tagName);
      const userName = userMap.get(row.userId);

      // 6. DBに存在しないデータがあった場合、行番号付きでエラーを返す
      if (!tagId) {
        return json({ ok: false, error: `${index + 2}行目: タグ "${row.tagName}" がDBに存在しません。` }, { status: 400 });
      }
      if (!userName) {
        return json({ ok: false, error: `${index + 2}行目: ユーザーID "${row.userId}" がDBに存在しません。` }, { status: 400 });
      }

      processedData.push({
        tagId,
        tagName: row.tagName,
        userId: row.userId,
        userName,
      });
    }

    return json({ ok: true, data: processedData });

  } catch (e) {
    console.error(e);
    return json({ ok: false, error: "サーバー内部でエラーが発生しました。" }, { status: 500 });
  }
};

### Step 2: フロントエンドコンポーネントの実装

ファイルをアップロードし、結果をプレビューするUIです。

app/routes/your-import-page.tsx
import { type ActionFunctionArgs, json, redirect } from "@remix-run/node";
import { Form, useFetcher } from "@remix-run/react";
import { useState, useEffect } from "react";
// APIルートから処理済みデータの型をインポート
import type { ProcessedData } from "./api.parse-tags-users-csv";

// APIからのレスポンスの型
type FetcherResponse = 
  | { ok: true; data: ProcessedData[] }
  | { ok: false; error: string; details?: any };

// このページのメインaction (最終的なデータを受け取る)
export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  // ... ここで最終的なデータを受け取りDBに保存する処理を実装 ...
  console.log("Final data received:", Object.fromEntries(formData));
  return redirect("/success");
};


export default function TagUserImportForm() {
  const fetcher = useFetcher<FetcherResponse>();
  const [processedData, setProcessedData] = useState<ProcessedData[]>([]);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  // fetcherの状態が変わったら、Stateを更新
  useEffect(() => {
    // 初期化
    setErrorMessage(null);
    if (fetcher.state === 'idle' && fetcher.data) {
      if (fetcher.data.ok) {
        setProcessedData(fetcher.data.data);
      } else {
        setProcessedData([]); // エラー時はデータを空にする
        setErrorMessage(fetcher.data.error);
      }
    }
  }, [fetcher.state, fetcher.data]);

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) return;

    const formData = new FormData();
    formData.append("csvFile", file);

    fetcher.submit(formData, {
      method: "post",
      encType: "multipart/form-data",
      action: "/api/parse-tags-users-csv",
    });
  };

  return (
    <div>
      <h1>CSVインポート</h1>

      {/* 1. ファイルアップロードセクション */}
      <div style={{ marginBottom: '2rem' }}>
        <label htmlFor="csv-upload">CSVファイルを選択:</label>
        <input
          id="csv-upload"
          type="file"
          name="csvFile"
          accept=".csv"
          onChange={handleFileChange}
          disabled={fetcher.state !== "idle"}
        />
        {fetcher.state !== "idle" && <p>⏳ 解析・検証中...</p>}
      </div>

      {/* 2. エラー表示セクション */}
      {errorMessage && (
        <div style={{ color: 'red', border: '1px solid red', padding: '1rem', marginBottom: '2rem' }}>
          <p>⛔️ エラーが発生しました:</p>
          <p>{errorMessage}</p>
        </div>
      )}

      {/* 3. プレビュー & 最終送信フォーム */}
      {processedData.length > 0 && (
        <Form method="post">
          <h2>プレビュー ({processedData.length}件)</h2>
          <table border={1} style={{ width: '100%', textAlign: 'left' }}>
            <thead>
              <tr>
                <th>タグ名</th>
                <th>ユーザー名</th>
              </tr>
            </thead>
            <tbody>
              {processedData.map((item, index) => (
                <tr key={`${item.tagId}-${item.userId}`}>
                  <td>
                    {item.tagName}
                    <input type="hidden" name={`items[${index}][tagId]`} value={item.tagId} />
                  </td>
                  <td>
                    {item.userName}
                    <input type="hidden" name={`items[${index}][userId]`} value={item.userId} />
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
          <br />
          <button type="submit">この内容で登録する</button>
        </Form>
      )}
    </div>
  );
}


## パターンB:クライアントサイドでパースする方式(非推奨だが参考用)

クライアントがCSVをパースし、tagNameuserIdの配列だけをサーバーに送ります。サーバーは対応するデータを返し、クライアントが最終的なデータを組み立てます。

### このパターンの課題点

  • エラーハンドリングの複雑化: サーバーから返ってきたエラー情報(例: '存在しないタグX')が、CSVの何行目に由来するのかをクライアント側で突き止めるロジックが必要になり、コードが複雑化します。
  • ロジックの分散: 機能ロジックがクライアントとサーバーに分散し、見通しが悪くなります。

### 全体フロー

  1. クライアント: ユーザーがファイルを選択します。
  2. クライアント: handleFileChangeが発火し、ブラウザ内でCSVをパースします
  3. クライアント: パース結果からtagNameuserIdの配列を抽出し、JSONとしてAPIルートにPOSTします。
  4. サーバー (APIルート): action関数がJSONを受け取ります。
  5. サーバー: 受け取ったtagNameuserIdを元にDBに問い合わせ、見つかったtagsusersのデータを返します。
  6. クライアント: fetcher.datatagsusersの配列を受け取ります。
  7. クライアント: **最初にパースしたCSVデータと、fetcherから返ってきたDBデータを突き合わせて(マージして)**最終的なデータ配列とエラーリストを生成します。
  8. クライアント: プレビューとエラーを表示します。
  9. クライアント: ユーザーが「登録」ボタンを押すと、完成したデータがページのメインactionに送信されます。

### Step 1: APIリソースルートの作成

JSONを受け取り、DBのデータを返すだけのシンプルなAPIです。

app/routes/api.get-tags-users.ts
import { type ActionFunctionArgs, json } from "@remix-run/node";
import { z } from "zod";
import { db } from "~/utils/db.server";

const Schema = z.object({
  tagNames: z.array(z.string()),
  userIds: z.array(z.string()),
});

export const action = async ({ request }: ActionFunctionArgs) => {
  const data = await request.json();
  const validation = Schema.safeParse(data);

  if (!validation.success) {
    return json({ error: "不正なリクエストです" }, { status: 400 });
  }

  const { tagNames, userIds } = validation.data;

  const [tags, users] = await Promise.all([
    db.tag.findMany({ where: { name: { in: tagNames } }, select: { id: true, name: true } }),
    db.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } }),
  ]);

  return json({ tags, users });
};

### Step 2: フロントエンドコンポーネントの実装

状態管理とマージ処理が大幅に複雑化します。

app/routes/your-import-page-alt.tsx
import { Form, useFetcher } from "@remix-run/react";
import { useState, useEffect } from "react";
import Papa from "papaparse"; // CSVパースライブラリ
import type { ProcessedData } from "./api.parse-tags-users-csv"; // 型は再利用

// CSVパース直後のデータの型
type CsvRow = { tagName: string; userId: string };
// APIから返ってくるデータの型
type FetchedData = {
  tags: { id: string; name: string }[];
  users: { id: string; name: string }[];
};

export default function TagUserImportFormAlt() {
  const fetcher = useFetcher<FetchedData>();
  const [parsedCsvData, setParsedCsvData] = useState<CsvRow[]>([]);
  const [processedData, setProcessedData] = useState<ProcessedData[]>([]);
  const [mergeErrors, setMergeErrors] = useState<string[]>([]);

  // 💥 データマージとエラーハンドリングのロジック (複雑な部分)
  useEffect(() => {
    if (parsedCsvData.length > 0 && fetcher.data) {
      const tagMap = new Map(fetcher.data.tags.map(t => [t.name, t.id]));
      const userMap = new Map(fetcher.data.users.map(u => [u.id, u.name]));
      
      const finalData: ProcessedData[] = [];
      const errors: string[] = [];

      parsedCsvData.forEach((row, index) => {
        const tagId = tagMap.get(row.tagName);
        const userName = userMap.get(row.userId);

        if (tagId && userName) {
          finalData.push({ tagId, tagName: row.tagName, userId: row.userId, userName });
        } else {
          if (!tagId) errors.push(`${index + 2}行目: タグ "${row.tagName}" が見つかりません。`);
          if (!userName) errors.push(`${index + 2}行目: ユーザーID "${row.userId}" が見つかりません。`);
        }
      });
      
      setProcessedData(finalData);
      setMergeErrors(errors);
    }
  }, [parsedCsvData, fetcher.data]);

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) return;

    // 各Stateを初期化
    setParsedCsvData([]);
    setProcessedData([]);
    setMergeErrors([]);

    // 1. クライアントでパース
    Papa.parse<CsvRow>(file, {
      header: true,
      skipEmptyLines: true,
      complete: (results) => {
        setParsedCsvData(results.data);
        // 2. ID/Nameの配列を抽出
        const tagNames = [...new Set(results.data.map(r => r.tagName))];
        const userIds = [...new Set(results.data.map(r => r.userId))];
        // 3. APIに送信
        fetcher.submit({ tagNames, userIds }, {
          method: "post",
          encType: "application/json",
          action: "/api/get-tags-users",
        });
      },
    });
  };

  return (
    <div>
      {/* ...UI部分はパターンAとほぼ同じだが、エラー表示をmergeErrorsから行う... */}
      <h1>CSVインポート (クライアントパース版)</h1>
      {/* ...ファイルアップロード部分... */}
      {mergeErrors.length > 0 && (
        <div style={{ color: 'red', border: '1px solid red', padding: '1rem', marginBottom: '2rem' }}>
          <h4>⛔️ エラー:</h4>
          <ul>{mergeErrors.map((err, i) => <li key={i}>{err}</li>)}</ul>
        </div>
      )}
      {/* ...プレビュー&最終送信フォーム... */}
    </div>
  );
}


## どちらを選ぶべきか

コードを見比べると分かる通り、**パターンA(サーバーサイド処理)**の方が、クライアント側のコードが圧倒的にシンプルで、責務も明確です。エラーハンドリングもサーバー側で一元管理できるため、より堅牢な実装になります。

特別な理由がない限り、パターンAを採用することをお勧めします。このコードが明日のあなたの助けになることを願っています。

aaaああああああああああああああああああああ-----

export default function CsvImportForm() {
  // ✅ 1. processedDataをuseStateで一元管理する。これが全ての「正」となる。
  const [processedData, setProcessedData] = useState<any[]>([]);

  const [form, fields] = useForm({
    onValidate({ formData }) {
      // ✅ 4. onValidateはDOMから作られた正しいformDataを受け取る
      return parseWithZod(formData, { schema: csvFormatArr });
    },
    // ...
  });

  const handleCsvParsed = (csvData: any[]) => {
    // ✅ 2. CSVの処理が完了したら、ReactのStateのみを更新する
    setProcessedData(csvData);
  };

  return (
    <div>
      {/* ... ファイルを処理し、handleCsvParsedを呼ぶUI ... */}

      <Form method="post" id={form.id}>
        {/*
          ✅ 3. Stateが更新されると再レンダリングが起き、
              この.map()が実行されてDOM上にinputが生成される
        */}
        {processedData.map((award, index) => (
          <div key={index}>
            <input {...getInputProps(fields.awards[index].date, { type: 'hidden' })} value={award.date} />
            <input {...getInputProps(fields.awards[index].title, { type: 'hidden' })} value={award.title} />
            {/* ... 他のフィールドも同様 ... */}
          </div>
        ))}

        {/* プレビュー表示も同じStateを元に行うので、データは常に同期している */}
        <table>
          <tbody>
            {processedData.map((award, index) => (
              <tr key={index}><td>{award.title}</td></tr>
            ))}
          </tbody>
        </table>

        <button type="submit">登録する</button>
      </Form>
    </div>
  );
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?