Upload Multiple Files dengan Progress Bar di React
Programming Tutorial React #react #typescript #axios #file-upload

Upload Multiple Files dengan Progress Bar di React

A
Abd. Asis
8 min read
Bagikan:

Fitur upload file yang terasa profesional bukan hanya soal mengirim data ke server — ini soal memberi feedback visual yang jelas kepada pengguna. Progress bar yang bergerak real-time untuk setiap file yang diupload adalah perbedaan antara aplikasi yang terasa responsif dan aplikasi yang terasa seperti freeze.

Membangunnya dari nol membutuhkan koordinasi beberapa hal sekaligus: state management yang tepat, upload paralel agar tidak mengantri satu per satu, dan komponen yang terstruktur rapi agar tidak menjadi satu file raksasa. Artikel ini membangun komponen upload multiple files lengkap — mulai dari input, daftar file, progress bar, hingga logika upload menggunakan Axios.

Arsitektur Komponen: Single Responsibility Principle

Sebelum menulis satu baris kode pun, penting untuk memikirkan bagaimana komponen akan dibagi. Semua logika akan berada dalam satu file FileUpload.tsx, tapi dibagi ke beberapa komponen internal yang masing-masing punya tanggung jawab tunggal.

Struktur yang akan dibangun:

  • FileUpload — komponen utama yang diekspor, mengelola semua state
  • FileInput — wrapper untuk HTML input file tersembunyi dan label-nya
  • FileList — merender daftar file yang sudah dipilih
  • FileItem — merender satu baris file dengan progress bar-nya
  • ProgressBar — komponen visual murni yang menampilkan progress
  • ActionButtons — tombol Upload dan Clear All

Pola ini mengikuti prinsip yang sama dengan yang dibahas di 4 React Hook Patterns yang Wajib Dikuasai Developer — pisahkan kekhawatiran, buat setiap bagian bisa diuji secara independen.

Mendefinisikan Type dan State

Langkah pertama adalah mendefinisikan tipe data. File dari HTML input tidak cukup — kita perlu menyimpan progress upload dan status selesai tidaknya setiap file.

// FileUpload.tsx
import { useState, useRef, ChangeEvent } from "react";
import axios from "axios";

interface FileWithProgress {
  id: string;
  file: File;
  progress: number;
  uploaded: boolean;
}

FileWithProgress membungkus objek File asli bersama metadata tambahan. Field id diisi dengan file.name karena sistem operasi umumnya tidak mengizinkan dua file dengan nama yang sama dalam satu folder — cukup sebagai identifier unik di sini.

State yang dibutuhkan hanya dua: daftar file dan status uploading.

const [files, setFiles] = useState<FileWithProgress[]>([]);
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

inputRef dibutuhkan untuk mereset nilai input secara manual setelah file dipilih. Ini perlu karena React mengelola state di memory, tapi elemen <input type="file"> juga menyimpan state-nya sendiri di DOM — keduanya harus disinkronkan.

Komponen FileInput: Menyembunyikan Input Asli

Browser memiliki input file bawaan yang sulit diatur tampilannya. Solusi standar adalah menyembunyikan input asli dan menggunakan <label> sebagai trigger melalui atribut htmlFor.

// FileUpload.tsx (lanjutan)

interface FileInputProps {
  inputRef: React.RefObject<HTMLInputElement>;
  disabled: boolean;
  onFileSelect: (e: ChangeEvent<HTMLInputElement>) => void;
}

function FileInput({ inputRef, disabled, onFileSelect }: FileInputProps) {
  return (
    <div>
      <input
        ref={inputRef}
        type="file"
        id="file-upload"
        multiple
        onChange={onFileSelect}
        disabled={disabled}
        className="hidden"
      />
      <label
        htmlFor="file-upload"
        className={`inline-flex items-center gap-2 px-4 py-2 rounded-md border text-sm font-medium cursor-pointer transition-colors
          ${disabled
            ? "opacity-50 cursor-not-allowed bg-gray-100 text-gray-400"
            : "bg-white hover:bg-gray-50 text-gray-700 border-gray-300"
          }`}
      >
        Pilih File
      </label>
    </div>
  );
}

Input diberi className="hidden" sehingga tidak tampil, sementara label mengambil peran visual sebagai tombol. Ketika label diklik, browser secara otomatis membuka dialog file karena htmlFor cocok dengan id input.

Mengelola Seleksi File di Komponen Utama

Fungsi handleFileSelect berada di komponen utama FileUpload karena ia membutuhkan akses ke setFiles dan inputRef.

// Di dalam komponen FileUpload

function handleFileSelect(e: ChangeEvent<HTMLInputElement>) {
  if (!e.target.files || e.target.files.length === 0) return;

  const newFiles: FileWithProgress[] = Array.from(e.target.files).map((file) => ({
    id: file.name,
    file,
    progress: 0,
    uploaded: false,
  }));

  setFiles((prev) => [...prev, ...newFiles]);

  // Reset nilai input agar file yang sama bisa dipilih lagi
  if (inputRef.current) {
    inputRef.current.value = "";
  }
}

Array.from(e.target.files) mengonversi FileList (objek browser) menjadi array JavaScript biasa yang bisa di-map. Setiap file mendapat progress: 0 sebagai nilai awal — progress bar akan mulai dari kosong.

Baris inputRef.current.value = "" penting: tanpa ini, jika pengguna memilih file yang sama dua kali, event onChange tidak akan terpicu karena browser menganggap tidak ada perubahan.

Komponen ProgressBar, FileItem, dan FileList

Tiga komponen ini bertanggung jawab untuk menampilkan file yang sudah dipilih. Mulai dari yang paling sederhana.

// FileUpload.tsx (lanjutan)

interface ProgressBarProps {
  progress: number;
}

function ProgressBar({ progress }: ProgressBarProps) {
  return (
    <div className="w-full bg-gray-200 rounded-full h-1.5 mt-2">
      <div
        className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
        style={{ width: `${progress}%` }}
      />
    </div>
  );
}

ProgressBar adalah komponen presentasional murni — tidak ada state, tidak ada efek, hanya menerima angka dan mengubahnya menjadi lebar CSS.

interface FileItemProps {
  file: FileWithProgress;
  uploading: boolean;
  onRemove: (id: string) => void;
}

function formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

function FileItem({ file, uploading, onRemove }: FileItemProps) {
  return (
    <div className="flex flex-col p-3 border rounded-lg bg-white">
      <div className="flex items-center justify-between">
        <div className="flex flex-col min-w-0">
          <span className="text-sm font-medium text-gray-800 truncate">
            {file.file.name}
          </span>
          <span className="text-xs text-gray-500 mt-0.5">
            {formatFileSize(file.file.size)} &middot; {file.file.type || "unknown"}
          </span>
        </div>
        <div className="flex items-center gap-3 ml-3 shrink-0">
          {file.uploaded ? (
            <span className="text-xs font-medium text-green-600">Selesai</span>
          ) : (
            <span className="text-xs text-gray-500">{Math.round(file.progress)}%</span>
          )}
          {!uploading && (
            <button
              onClick={() => onRemove(file.id)}
              className="text-gray-400 hover:text-red-500 transition-colors text-lg leading-none"
              aria-label={`Hapus ${file.file.name}`}
            >
              &times;
            </button>
          )}
        </div>
      </div>
      {!file.uploaded && <ProgressBar progress={file.progress} />}
    </div>
  );
}

FileItem memutuskan apa yang ditampilkan berdasarkan state file: jika uploaded true maka tampilkan teks “Selesai”, jika belum maka tampilkan persentase. Tombol hapus disembunyikan saat uploading aktif — tidak boleh memodifikasi daftar file di tengah proses.

interface FileListProps {
  files: FileWithProgress[];
  uploading: boolean;
  onRemove: (id: string) => void;
}

function FileList({ files, uploading, onRemove }: FileListProps) {
  if (files.length === 0) return null;

  return (
    <div className="mt-4">
      <h3 className="text-sm font-semibold text-gray-600 mb-2 uppercase tracking-wide">
        File ({files.length})
      </h3>
      <div className="flex flex-col gap-2">
        {files.map((file) => (
          <FileItem
            key={file.id}
            file={file}
            uploading={uploading}
            onRemove={onRemove}
          />
        ))}
      </div>
    </div>
  );
}

FileList melakukan early return null jika tidak ada file — ini mencegah elemen HTML yang tidak perlu merusak layout saat daftar masih kosong.

ActionButtons: Tombol dengan Kondisi Berbeda

Tombol Upload dan Clear All memiliki kondisi disabled yang berbeda dari FileInput. Input harus tetap aktif selama tidak sedang uploading, tapi kedua tombol aksi ini harus dinonaktifkan baik saat uploading maupun saat tidak ada file.

interface ActionButtonsProps {
  disabled: boolean;
  onUpload: () => void;
  onClear: () => void;
}

function ActionButtons({ disabled, onUpload, onClear }: ActionButtonsProps) {
  return (
    <div className="flex gap-2">
      <button
        onClick={onUpload}
        disabled={disabled}
        className="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
      >
        Upload
      </button>
      <button
        onClick={onClear}
        disabled={disabled}
        className="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
      >
        Hapus Semua
      </button>
    </div>
  );
}

Pisahkan komponen ini bukan hanya untuk kebersihan kode — ini memungkinkan kita mengoper disabled yang berbeda ke setiap elemen. Di sini keduanya berbagi satu prop disabled, tapi jika suatu saat dibutuhkan kontrol lebih granular, tinggal pisahkan prop-nya.

Logika Upload Paralel dengan Promise.all

Inilah inti dari seluruh komponen. Upload paralel berarti semua file dikirim ke server secara bersamaan — tidak menunggu file pertama selesai baru mulai file kedua.

// Di dalam komponen FileUpload

function removeFile(id: string) {
  setFiles((prev) => prev.filter((f) => f.id !== id));
}

function handleClear() {
  setFiles([]);
}

async function handleUpload() {
  if (files.length === 0 || uploading) return;

  setUploading(true);

  const uploadPromises = files.map((fileWithProgress) => {
    const formData = new FormData();
    formData.append("file", fileWithProgress.file);

    return axios
      .post("https://httpbin.org/post", formData, {
        onUploadProgress: (progressEvent) => {
          const progress = Math.round(
            (progressEvent.loaded * 100) / (progressEvent.total ?? 1)
          );

          setFiles((prev) =>
            prev.map((f) =>
              f.id === fileWithProgress.id ? { ...f, progress } : f
            )
          );
        },
      })
      .then(() => {
        setFiles((prev) =>
          prev.map((f) =>
            f.id === fileWithProgress.id ? { ...f, uploaded: true, progress: 100 } : f
          )
        );
      })
      .catch((error) => {
        console.error(`Gagal mengupload ${fileWithProgress.file.name}:`, error);
      });
  });

  await Promise.all(uploadPromises);
  setUploading(false);
}

Ada dua hal penting di sini yang perlu dipahami.

Pertama, di dalam onUploadProgress, setFiles dipanggil dengan fungsi updater (prev) => ... bukan dengan nilai langsung. Ini krusial karena beberapa upload berjalan paralel — jika menggunakan nilai state langsung, update dari upload A bisa menimpa update dari upload B yang terjadi di waktu yang hampir bersamaan. Dengan fungsi updater, React menjamin setiap update menerima state terbaru.

Kedua, uploadPromises adalah array berisi Promise — mereka belum berjalan saat dibuat. Promise.all yang kemudian menjalankan semuanya secara bersamaan. await Promise.all(uploadPromises) menunggu hingga semua upload selesai sebelum memanggil setUploading(false).

Endpoint https://httpbin.org/post adalah layanan gratis yang menerima request HTTP dan mengembalikan data request sebagai response. Cocok untuk pengujian tanpa perlu backend sendiri.

Menyatukan Semua di Komponen Utama

Semua komponen dan fungsi dikomposes di dalam FileUpload, satu-satunya komponen yang diekspor dari file ini.

// FileUpload.tsx (komponen utama)

export default function FileUpload() {
  const [files, setFiles] = useState<FileWithProgress[]>([]);
  const [uploading, setUploading] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  function handleFileSelect(e: ChangeEvent<HTMLInputElement>) {
    if (!e.target.files || e.target.files.length === 0) return;

    const newFiles: FileWithProgress[] = Array.from(e.target.files).map((file) => ({
      id: file.name,
      file,
      progress: 0,
      uploaded: false,
    }));

    setFiles((prev) => [...prev, ...newFiles]);

    if (inputRef.current) {
      inputRef.current.value = "";
    }
  }

  function removeFile(id: string) {
    setFiles((prev) => prev.filter((f) => f.id !== id));
  }

  function handleClear() {
    setFiles([]);
  }

  async function handleUpload() {
    if (files.length === 0 || uploading) return;

    setUploading(true);

    const uploadPromises = files.map((fileWithProgress) => {
      const formData = new FormData();
      formData.append("file", fileWithProgress.file);

      return axios
        .post("https://httpbin.org/post", formData, {
          onUploadProgress: (progressEvent) => {
            const progress = Math.round(
              (progressEvent.loaded * 100) / (progressEvent.total ?? 1)
            );

            setFiles((prev) =>
              prev.map((f) =>
                f.id === fileWithProgress.id ? { ...f, progress } : f
              )
            );
          },
        })
        .then(() => {
          setFiles((prev) =>
            prev.map((f) =>
              f.id === fileWithProgress.id ? { ...f, uploaded: true, progress: 100 } : f
            )
          );
        })
        .catch((error) => {
          console.error(`Gagal mengupload ${fileWithProgress.file.name}:`, error);
        });
    });

    await Promise.all(uploadPromises);
    setUploading(false);
  }

  const actionsDisabled = files.length === 0 || uploading;

  return (
    <div className="max-w-lg mx-auto p-6 bg-gray-50 rounded-xl border border-gray-200">
      <div className="flex items-center justify-between mb-4">
        <h2 className="text-lg font-semibold text-gray-800">Upload File</h2>
        <div className="flex gap-2">
          <FileInput
            inputRef={inputRef}
            disabled={uploading}
            onFileSelect={handleFileSelect}
          />
          <ActionButtons
            disabled={actionsDisabled}
            onUpload={handleUpload}
            onClear={handleClear}
          />
        </div>
      </div>
      <FileList
        files={files}
        uploading={uploading}
        onRemove={removeFile}
      />
    </div>
  );
}

Perhatikan perbedaan disabled antara FileInput dan ActionButtons: input hanya dinonaktifkan saat uploading, sementara tombol aksi dinonaktifkan saat uploading atau saat files.length === 0.

Hal yang Perlu Diperhatikan

Beberapa edge case yang sering muncul saat menggunakan komponen ini di production:

  • File duplikat: Menggunakan file.name sebagai ID berarti dua file dengan nama yang sama dari folder berbeda akan dianggap satu file. Jika ini menjadi masalah, gunakan crypto.randomUUID() sebagai ID alih-alih nama file.
  • Batas ukuran file: Komponen ini tidak melakukan validasi ukuran atau tipe file. Tambahkan validasi di dalam handleFileSelect sebelum memasukkan file ke state.
  • Penanganan error per file: Saat ini error hanya di-log ke konsol. Di aplikasi production, tambahkan field error di FileWithProgress dan tampilkan pesan error di FileItem.

onUploadProgress dari Axios hanya berfungsi di browser, bukan di Node.js. Jika menggunakan komponen ini di lingkungan SSR seperti Next.js, pastikan komponen ini hanya dirender di client dengan "use client" directive.

Kesimpulan

Upload multiple files dengan progress bar yang sesungguhnya bukan hanya soal <input multiple> — ini tentang state yang terstruktur, update konkuren yang aman dari race condition, dan komponen yang cukup kecil untuk dipahami sekaligus cukup terkomposisi untuk dikembangkan. Promise.all yang menjalankan semua upload paralel, dikombinasikan dengan functional update di setFiles, adalah fondasi dari keseluruhan logika ini. Dari sini, langkah lanjutannya bisa berupa validasi file, drag-and-drop support, atau integrasi dengan React Query untuk mengelola state upload di level yang lebih global.

Referensi

  1. 1Axios Request Config — Dokumentasi resmi konfigurasi onUploadProgress dan onDownloadProgress
  2. 2React Docs — Updating objects and arrays in state (functional updater pattern)
  3. 3MDN Web Docs — FormData API untuk mengirim file melalui HTTP request
  4. 4MDN Web Docs — Promise.all untuk menjalankan multiple promises secara paralel

Tentang Penulis

Abd. Asis

Abd. Asis

Software Developer dan Laravel Programmer dari Madura, Indonesia. Passionate tentang PHP, Laravel, dan teknologi web modern.

Artikel Terkait

Artikel lain yang mungkin menarik untuk kamu