Skip to main content
Back to Blog
Tutorials
4 min read
November 29, 2024

How to Build a CSV Import Tool in React

Create a CSV import tool with file parsing, column mapping, validation, and preview before importing data.

Ryel Banfield

Founder & Lead Developer

Data import is a common requirement for SaaS applications. Here is how to build a polished CSV import experience.

Step 1: Install Dependencies

pnpm add papaparse
pnpm add -D @types/papaparse

Step 2: CSV Parser Hook

// hooks/useCsvParser.ts
"use client";

import { useState, useCallback } from "react";
import Papa from "papaparse";

interface CsvData {
  headers: string[];
  rows: Record<string, string>[];
  rowCount: number;
}

export function useCsvParser() {
  const [data, setData] = useState<CsvData | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  const parseFile = useCallback((file: File) => {
    setIsLoading(true);
    setError(null);

    if (!file.name.endsWith(".csv")) {
      setError("Please upload a CSV file");
      setIsLoading(false);
      return;
    }

    if (file.size > 10 * 1024 * 1024) {
      setError("File size must be under 10MB");
      setIsLoading(false);
      return;
    }

    Papa.parse(file, {
      header: true,
      skipEmptyLines: true,
      transformHeader: (header) => header.trim(),
      complete: (results) => {
        const headers = results.meta.fields ?? [];
        const rows = results.data as Record<string, string>[];

        setData({
          headers,
          rows,
          rowCount: rows.length,
        });
        setIsLoading(false);
      },
      error: (err) => {
        setError(`Parse error: ${err.message}`);
        setIsLoading(false);
      },
    });
  }, []);

  const reset = useCallback(() => {
    setData(null);
    setError(null);
  }, []);

  return { data, error, isLoading, parseFile, reset };
}

Step 3: File Dropzone

"use client";

import { useState, useCallback } from "react";
import { Upload, FileText } from "lucide-react";

interface DropzoneProps {
  onFile: (file: File) => void;
  accept?: string;
}

export function Dropzone({ onFile, accept = ".csv" }: DropzoneProps) {
  const [isDragging, setIsDragging] = useState(false);

  const handleDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    setIsDragging(true);
  }, []);

  const handleDragLeave = useCallback(() => {
    setIsDragging(false);
  }, []);

  const handleDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault();
      setIsDragging(false);

      const file = e.dataTransfer.files[0];
      if (file) onFile(file);
    },
    [onFile]
  );

  return (
    <div
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
      className={`flex flex-col items-center justify-center rounded-2xl border-2 border-dashed p-12 transition-colors ${
        isDragging
          ? "border-blue-500 bg-blue-50 dark:bg-blue-950/20"
          : "border-gray-300 dark:border-gray-700"
      }`}
    >
      <Upload className="mb-4 h-10 w-10 text-gray-400" />
      <p className="mb-2 text-sm font-medium">
        Drag and drop your CSV file here
      </p>
      <p className="mb-4 text-xs text-gray-400">or</p>
      <label className="cursor-pointer rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
        Browse Files
        <input
          type="file"
          accept={accept}
          onChange={(e) => {
            const file = e.target.files?.[0];
            if (file) onFile(file);
          }}
          className="hidden"
        />
      </label>
    </div>
  );
}

Step 4: Column Mapper

"use client";

interface ColumnMapperProps {
  csvHeaders: string[];
  targetFields: { key: string; label: string; required: boolean }[];
  mapping: Record<string, string>;
  onChange: (mapping: Record<string, string>) => void;
}

export function ColumnMapper({
  csvHeaders,
  targetFields,
  mapping,
  onChange,
}: ColumnMapperProps) {
  function autoMap() {
    const newMapping: Record<string, string> = {};
    targetFields.forEach((field) => {
      const match = csvHeaders.find(
        (h) =>
          h.toLowerCase() === field.key.toLowerCase() ||
          h.toLowerCase() === field.label.toLowerCase() ||
          h.toLowerCase().replace(/[_\s]/g, "") ===
            field.key.toLowerCase().replace(/[_\s]/g, "")
      );
      if (match) newMapping[field.key] = match;
    });
    onChange(newMapping);
  }

  return (
    <div className="space-y-4">
      <div className="flex items-center justify-between">
        <h3 className="text-sm font-semibold">Map Columns</h3>
        <button
          onClick={autoMap}
          className="text-xs text-blue-600 hover:underline"
        >
          Auto-detect
        </button>
      </div>

      <div className="space-y-2">
        {targetFields.map((field) => (
          <div key={field.key} className="flex items-center gap-3">
            <label className="w-40 text-sm">
              {field.label}
              {field.required && <span className="ml-1 text-red-500">*</span>}
            </label>
            <select
              value={mapping[field.key] ?? ""}
              onChange={(e) =>
                onChange({ ...mapping, [field.key]: e.target.value })
              }
              className="flex-1 rounded-lg border px-3 py-1.5 text-sm dark:border-gray-700 dark:bg-gray-800"
            >
              <option value="">-- Skip --</option>
              {csvHeaders.map((header) => (
                <option key={header} value={header}>
                  {header}
                </option>
              ))}
            </select>
          </div>
        ))}
      </div>
    </div>
  );
}

Step 5: Data Preview with Validation

"use client";

import { useMemo } from "react";
import { AlertCircle, CheckCircle } from "lucide-react";

interface DataPreviewProps {
  rows: Record<string, string>[];
  mapping: Record<string, string>;
  validators?: Record<string, (value: string) => string | null>;
  maxPreview?: number;
}

export function DataPreview({
  rows,
  mapping,
  validators = {},
  maxPreview = 10,
}: DataPreviewProps) {
  const mappedFields = Object.entries(mapping).filter(([, v]) => v);

  const validatedRows = useMemo(() => {
    return rows.slice(0, maxPreview).map((row) => {
      const errors: Record<string, string> = {};
      mappedFields.forEach(([targetKey, csvHeader]) => {
        const value = row[csvHeader] ?? "";
        const validator = validators[targetKey];
        if (validator) {
          const err = validator(value);
          if (err) errors[targetKey] = err;
        }
      });
      return { row, errors, isValid: Object.keys(errors).length === 0 };
    });
  }, [rows, mapping, validators, maxPreview, mappedFields]);

  const errorCount = validatedRows.filter((r) => !r.isValid).length;
  const totalErrors = rows.length > maxPreview
    ? Math.round((errorCount / maxPreview) * rows.length)
    : errorCount;

  return (
    <div className="space-y-3">
      <div className="flex items-center gap-2 text-sm">
        {errorCount === 0 ? (
          <>
            <CheckCircle className="h-4 w-4 text-green-500" />
            <span>All {rows.length} rows look good</span>
          </>
        ) : (
          <>
            <AlertCircle className="h-4 w-4 text-amber-500" />
            <span>~{totalErrors} rows have issues (showing first {maxPreview})</span>
          </>
        )}
      </div>

      <div className="overflow-x-auto rounded-lg border dark:border-gray-700">
        <table className="w-full text-left text-sm">
          <thead className="bg-gray-50 dark:bg-gray-800">
            <tr>
              <th className="px-3 py-2 text-xs font-medium text-gray-500">#</th>
              {mappedFields.map(([key]) => (
                <th key={key} className="px-3 py-2 text-xs font-medium text-gray-500">
                  {key}
                </th>
              ))}
              <th className="px-3 py-2 text-xs font-medium text-gray-500">Status</th>
            </tr>
          </thead>
          <tbody>
            {validatedRows.map(({ row, errors, isValid }, idx) => (
              <tr key={idx} className={!isValid ? "bg-red-50 dark:bg-red-950/10" : ""}>
                <td className="px-3 py-2 text-xs text-gray-400">{idx + 1}</td>
                {mappedFields.map(([targetKey, csvHeader]) => (
                  <td
                    key={targetKey}
                    className={`px-3 py-2 ${errors[targetKey] ? "text-red-600" : ""}`}
                    title={errors[targetKey] ?? undefined}
                  >
                    {row[csvHeader] ?? "—"}
                  </td>
                ))}
                <td className="px-3 py-2">
                  {isValid ? (
                    <CheckCircle className="h-4 w-4 text-green-500" />
                  ) : (
                    <AlertCircle className="h-4 w-4 text-red-500" />
                  )}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

Step 6: Putting It Together

"use client";

import { useState } from "react";
import { useCsvParser } from "@/hooks/useCsvParser";
import { Dropzone } from "./Dropzone";
import { ColumnMapper } from "./ColumnMapper";
import { DataPreview } from "./DataPreview";

const TARGET_FIELDS = [
  { key: "name", label: "Full Name", required: true },
  { key: "email", label: "Email", required: true },
  { key: "phone", label: "Phone", required: false },
  { key: "company", label: "Company", required: false },
];

const VALIDATORS: Record<string, (v: string) => string | null> = {
  name: (v) => (v.trim().length < 2 ? "Name too short" : null),
  email: (v) => (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : "Invalid email"),
};

export function CsvImporter() {
  const { data, error, isLoading, parseFile, reset } = useCsvParser();
  const [mapping, setMapping] = useState<Record<string, string>>({});
  const [step, setStep] = useState<"upload" | "map" | "preview" | "complete">("upload");

  if (step === "upload") {
    return (
      <Dropzone
        onFile={(file) => {
          parseFile(file);
          setStep("map");
        }}
      />
    );
  }

  if (step === "map" && data) {
    return (
      <div className="space-y-6">
        <ColumnMapper
          csvHeaders={data.headers}
          targetFields={TARGET_FIELDS}
          mapping={mapping}
          onChange={setMapping}
        />
        <div className="flex justify-between">
          <button onClick={() => { reset(); setStep("upload"); }} className="text-sm text-gray-500">
            Back
          </button>
          <button
            onClick={() => setStep("preview")}
            className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white"
          >
            Preview Import
          </button>
        </div>
      </div>
    );
  }

  if (step === "preview" && data) {
    return (
      <div className="space-y-6">
        <DataPreview rows={data.rows} mapping={mapping} validators={VALIDATORS} />
        <div className="flex justify-between">
          <button onClick={() => setStep("map")} className="text-sm text-gray-500">
            Back
          </button>
          <button
            onClick={() => {
              // Submit to API here
              setStep("complete");
            }}
            className="rounded-lg bg-green-600 px-4 py-2 text-sm text-white"
          >
            Import {data.rowCount} Rows
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="text-center">
      <p className="text-lg font-semibold text-green-600">Import Complete</p>
      <button onClick={() => { reset(); setStep("upload"); }} className="mt-4 text-sm text-blue-600">
        Import Another File
      </button>
    </div>
  );
}

Summary

  • Multi-step wizard: upload, map columns, preview, import
  • Auto-detect column mappings by name similarity
  • Validate data before importing
  • Handle large files with PapaParse streaming

Need Data Import Features?

We build data management tools with seamless import and export workflows. Contact us to discuss your project.

CSVimportfile uploaddataReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles