Handling Complex File Upload forms

Picture this: You're working on a mature React application with forms that have evolved over years. Nested objects, array fields, complex validation and maybe even some legacy code. Then comes the requirement: "We need to add file uploads to this form." Your first thought? "Easy, just use FormData and multipart uploads!" But then you open the form component and see something this: // The existing form structure that gives you nightmares const { control, handleSubmit } = useForm(); Suddenly, converting everything to FormData feels like impossible and just not ideal. Let me show you how we tackled this without rewriting our entire form (and backend!). The Problem of File Uploads Our requirements were: ✅ Maintain the existing complex JSON structure ✅ Add multiple file uploads ✅ Single API request (no race conditions!) ✅ No massive refactoring The Obvious (But Flawed) Solution: Two Separate Requests Submitting form data first, then uploading files separately seemed logical. But: ❌ What if the form submission succeeds but file upload fails? ❌ How do we handle partial failures? ❌ Double the API surface = double the maintenance We needed a way to send everything in one payload. Here's what we built. Approach 1: The Straightforward FormData Method Best for: Simple forms, quick implementations Let's start with the solution you wish you could use. Simple Upload Component // app/components/simple-upload.tsx "use client"; export default function SimpleUploadForm() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(); files.forEach((file) => formData.append("files", file)); const res = await fetch("/api/simple-upload", { method: "POST", body: formData, // No Content-Type header! }); }; return ( Upload ); } API Route // app/api/simple-upload/route.ts export async function POST(request: NextRequest) { const formData = await request.formData(); const files = formData.getAll("files") as File[]; files.forEach(async (file) => { const bytes = await file.arrayBuffer(); const buffer = Buffer.from(bytes); // Write to filesystem or S3 }); return NextResponse.json({ success: true }); } ✅ Why this works: Native browser support Streams files directly No size conversion overhead ❌ Why it failed us: Our existing form wasn't compatible with FormData Converting nested objects to flat key-value pairs would require: Frontend restructuring Backend parameter re-mapping Breaking changes for mobile clients Approach 2: The JSON Base64 Shuffle Best for: Keeping complex JSON structures intact Instead of breaking our form, we converted files to Base64 and embedded them in JSON. So the flow will be: Frontend will encode it and send to backend. Backend will then decode it and be able to treat it (Save to an S3 bucket, save to database, whatever.). Complex Upload Component // app/components/complex-upload.tsx "use client"; const onSubmit: SubmitHandler = async (data) => { const files = await Promise.all( Array.from(data.files).map(async (file) => { const base64 = await readFileAsBase64(file); return { name: file.name, type: file.type, size: file.size, base64: base64.split(",")[1], // Strip metadata }; }) ); const payload = { user: data.user, attachments: files, }; await fetch("/api/complex-upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); }; // FileReader Helper Function const readFileAsBase64 = (file: File) => { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = (e) => resolve(e.target?.result as string); reader.readAsDataURL(file); }); }; API Route // app/api/complex-upload/route.ts export async function POST(request: NextRequest) { const { user, attachments } = await request.json(); attachments.forEach((file) => { const buffer = Buffer.from(file.base64, "base64"); fs.writeFileSync(`./uploads/${file.name}`, buffer); }); return NextResponse.json({ user, uploadedFiles: attachments.map(f => f.name) }); } ✅ Why this works: Existing structure maintained: No need to flatten nested objects Single request: Everything succeeds or fails together Backend agnostic: Works with REST, GraphQL, or even tRPC Lessons 1. React Hook Form Quirks When using useForm with file inputs, handle the FileList manually: {...register("files", { validate: (files) => files?.length > 0 || "Required" })} 2. Memory Management Converting 100MB files to base64?

Feb 9, 2025 - 17:38
 0
Handling Complex File Upload forms

Picture this: You're working on a mature React application with forms that have evolved over years. Nested objects, array fields, complex validation and maybe even some legacy code. Then comes the requirement:

"We need to add file uploads to this form."

Your first thought? "Easy, just use FormData and multipart uploads!"

But then you open the form component and see something this:

// The existing form structure that gives you nightmares
const { control, handleSubmit } = useForm<{
  project: {
    name: string;
    collaborators: Array<{
      email: string;
      permissions: string[];
    }>;
    timeline: {
      start: Date;
      milestones: Array<{
        title: string;
        description: string;
      }>;
    };
  };
  // ...and 20 more fields
}>();

Suddenly, converting everything to FormData feels like impossible and just not ideal. Let me show you how we tackled this without rewriting our entire form (and backend!).

The Problem of File Uploads

Our requirements were:

✅ Maintain the existing complex JSON structure

✅ Add multiple file uploads

✅ Single API request (no race conditions!)

✅ No massive refactoring

The Obvious (But Flawed) Solution: Two Separate Requests

Submitting form data first, then uploading files separately seemed logical.

But:

❌ What if the form submission succeeds but file upload fails?

❌ How do we handle partial failures?

❌ Double the API surface = double the maintenance

We needed a way to send everything in one payload. Here's what we built.

Approach 1: The Straightforward FormData Method

Best for: Simple forms, quick implementations

Let's start with the solution you wish you could use.

Simple Upload Component

// app/components/simple-upload.tsx
"use client";

export default function SimpleUploadForm() {
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const formData = new FormData();
    files.forEach((file) => formData.append("files", file));

    const res = await fetch("/api/simple-upload", {
      method: "POST",
      body: formData, // No Content-Type header!
    });
  };

  return (
    <Card>
      <CardContent>
        <form onSubmit={handleSubmit}>
          <Input type="file" multiple />
          <Button type="submit">UploadButton>
        form>
      CardContent>
    Card>
  );
}

API Route

// app/api/simple-upload/route.ts
export async function POST(request: NextRequest) {
  const formData = await request.formData();
  const files = formData.getAll("files") as File[];

  files.forEach(async (file) => {
    const bytes = await file.arrayBuffer();
    const buffer = Buffer.from(bytes);
    // Write to filesystem or S3
  });

  return NextResponse.json({ success: true });
}

Why this works:

  • Native browser support
  • Streams files directly
  • No size conversion overhead

Why it failed us:

  • Our existing form wasn't compatible with FormData
  • Converting nested objects to flat key-value pairs would require:
    • Frontend restructuring
    • Backend parameter re-mapping
    • Breaking changes for mobile clients

Approach 2: The JSON Base64 Shuffle

Best for: Keeping complex JSON structures intact

Instead of breaking our form, we converted files to Base64 and embedded them in JSON. So the flow will be:

Frontend will encode it and send to backend.
Backend will then decode it and be able to treat it (Save to an S3 bucket, save to database, whatever.).

Complex Upload Component

// app/components/complex-upload.tsx
"use client";

const onSubmit: SubmitHandler<FormInputs> = async (data) => {
  const files = await Promise.all(
    Array.from(data.files).map(async (file) => {
      const base64 = await readFileAsBase64(file);
      return {
        name: file.name,
        type: file.type,
        size: file.size,
        base64: base64.split(",")[1], // Strip metadata
      };
    })
  );

  const payload = {
    user: data.user,
    attachments: files,
  };

  await fetch("/api/complex-upload", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
};

// FileReader Helper Function
const readFileAsBase64 = (file: File) => {
  return new Promise<string>((resolve) => {
    const reader = new FileReader();
    reader.onload = (e) => resolve(e.target?.result as string);
    reader.readAsDataURL(file);
  });
};

API Route

// app/api/complex-upload/route.ts
export async function POST(request: NextRequest) {
  const { user, attachments } = await request.json();

  attachments.forEach((file) => {
    const buffer = Buffer.from(file.base64, "base64");
    fs.writeFileSync(`./uploads/${file.name}`, buffer);
  });

  return NextResponse.json({ 
    user,
    uploadedFiles: attachments.map(f => f.name)
  });
}

Why this works:

  • Existing structure maintained: No need to flatten nested objects
  • Single request: Everything succeeds or fails together
  • Backend agnostic: Works with REST, GraphQL, or even tRPC

Lessons

1. React Hook Form Quirks

When using useForm with file inputs, handle the FileList manually:

{...register("files", { 
  validate: (files) => files?.length > 0 || "Required"
})}

2. Memory Management

Converting 100MB files to base64?