React Dropzone for AWS (S3 + Cloudfront) using ShadCN/UI and React Hook Form.

Shrinidhi N Hegde
5 min readDec 22, 2024

--

Everytime I work with forms to upload files to S3, I have to work with a few tools like React DropZone, React hook form, s3 with cloudfront. Integrating all these into one single component seems pretty straight forward but there is no straight forward documentation on the internet. So here I am trying to make a small component which I can re-use in the future.

Firstly we need to setup s3 along with cloudfront. I have previously written a blog on the same. You can find it here. Please be mindful that the UI in AWS has changed but the steps remain the same.

I only came up with this because I do not want to reinvent the wheel everytime I am working with a s3 file upload in one of my front end applications.

import {useDropzone} from "react-dropzone";
import React, {ChangeEventHandler, FC, useCallback, useState} from "react";
import {UploadIcon, XIcon} from "lucide-react";
import {Input} from "@/components/ui/input";
import {useFormContext} from "react-hook-form";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {Button} from "@/components/ui/button";
import {Progress} from "@/components/ui/progress";
import {uploadFileToS3} from "@/lib/utils/utils";

interface DropzoneFieldProps {
name: string;
filePathName: string;
multiple?: boolean;
destinationPathPrefix: string;
description?: string;
}

export const DropzoneField: FC<DropzoneFieldProps> = ({
name,
filePathName,
multiple,
destinationPathPrefix,
description,
...rest
}) => {
const {setValue, getValues} = useFormContext();
const files = getValues(name);
const filePaths = getValues(filePathName);
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
const [abortControllers, setAbortControllers] = useState<Record<string, AbortController>>({});

const onDrop = useCallback(
async (droppedFiles: File[]) => {
if (multiple) {
const newFiles = (!!files?.length && [...files].concat(droppedFiles)) || droppedFiles;
setValue(name, newFiles, {shouldValidate: true});
} else {
setValue(name, droppedFiles[0], {shouldValidate: true});
}

for (const file of droppedFiles) {
const controller = new AbortController();
setAbortControllers((prev) => ({...prev, [file.name]: controller}));
const destinationPath = `${destinationPathPrefix.endsWith('/') ? destinationPathPrefix.slice(0, -1) : destinationPathPrefix}/${file.name}`;
await uploadFileToS3({
file,
destinationPath,
signal: controller.signal,
onProgress: (progress) => {
setUploadProgress((prev) => ({ ...prev, [file.name]: progress }));
if (progress === 100) {
setValue(filePathName, { ...filePaths, [file.name]: destinationPath });
}
}
});
}
},
[multiple, files, setValue, name, destinationPathPrefix, filePathName, filePaths],
);
const onChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file_array = Array.from(e.target.files || []);
await onDrop(file_array);
}
const handleCancelUpload = (fileName: string) => {
if (multiple) {
setValue(name, files.filter((f: File) => f.name !== fileName), {shouldValidate: true});
} else {
setValue(name, undefined, {shouldValidate: true});
}
if (abortControllers[fileName]) {
abortControllers[fileName].abort();
setAbortControllers((prev) => {
const newControllers = {...prev};
delete newControllers[fileName];
return newControllers;
});
}
};

return (
<>
{multiple ? (
<Dropzone
multiple={multiple}
onDrop={onDrop}
{...rest}
description={description}
onChange={onChange}
/>
) : !files && (
<Dropzone
multiple={multiple}
onDrop={onDrop}
{...rest}
description={description}
onChange={onChange}
/>
)}
{files && (
<div className="mt-4 space-y-2">
{multiple ? files.map((file: File, index: number) => (
// this is for multiple files. When multiple files are selected, each file will be shown with a cancel button along with the dropzone thing.
<Card key={index} className="relative">
<Button variant="ghost" className="absolute top-2 right-2" onClick={() => handleCancelUpload(file.name)}>
<XIcon className="h-5 w-5"/>
</Button>
<CardHeader>
<CardTitle>{file.name}</CardTitle>
<CardDescription>{(file.size / 1024).toFixed(2)} KB</CardDescription>
</CardHeader>
<CardContent>
<Progress value={uploadProgress[file.name] || 0}/>
</CardContent>
</Card>
)) : (
// this is for a single file. When file is selected, only the file preview will be shown with a cancel button.
<Card className="relative">
<Button variant="ghost" className="absolute top-2 right-2" onClick={() => handleCancelUpload(files.name)}>
<XIcon className="h-5 w-5"/>
</Button>
<CardHeader>
<CardTitle>{files.name}</CardTitle>
<CardDescription>{(files.size / 1024).toFixed(2)} KB</CardDescription>
</CardHeader>
<CardContent>
<Progress value={uploadProgress[files.name] || 0}/>
</CardContent>
</Card>
)}
</div>
)}
</>
);
};

const Dropzone: FC<{
multiple?: boolean;
description?: string;
onDrop: (acceptedFiles: File[]) => void;
onChange?: ChangeEventHandler<HTMLInputElement>;
}> = ({multiple, description, onChange, onDrop, ...rest}) => {
const {getRootProps, getInputProps, isDragActive} = useDropzone({
multiple,
...rest,
onDrop
});

return (
<div {...getRootProps()}>
{isDragActive ? (
// this is the drag active state, you can style this as you like
<div
className="flex flex-col items-center justify-center bg-gray-400 space-y-4 py-12 px-6 border-2 border-gray-300 border-dashed rounded-md transition-colors hover:border-gray-400 focus-within:ring-2 focus-within:ring-primary focus-within:border-transparent">
<UploadIcon className="h-12 w-12 text-gray-600"/>
<div className="font-medium text-gray-900 dark:text-gray-50">Please Drop
the {multiple ? 'files' : 'file'} within the highlighted
area.
</div>
{description && <div className="text-sm text-gray-500">{description}</div>}
</div>
) : (
// this is the default state, you can style this as you like
<div
className="flex flex-col items-center justify-center space-y-4 py-12 px-6 border-2 border-gray-300 border-dashed rounded-md transition-colors hover:border-gray-400 focus-within:ring-2 focus-within:ring-primary focus-within:border-transparent">
<UploadIcon className="h-12 w-12 text-gray-600"/>
<div className="font-medium text-gray-900 dark:text-gray-50">Drop {multiple ? 'files' : 'file'} here or click
to upload
</div>
{description && <div className="text-sm text-gray-500">{description}</div>}
<Input {...getInputProps({onChange})} />
</div>
)}
</div>
);
};

I came up with this DropzoneField which works well with React Hook Form. I also have 2 code snippets which needs to be present in order for the Component to work properly. uploadFileToS3 function…

import {getPreSignedURL} from "@/lib/utils/preSignedURL";

interface UploadParams {
file: Buffer | Blob;
destinationPath: string;
signal: AbortSignal;
onProgress: (progress: number) => void;
}

export const uploadFileToS3 = async ({ file, destinationPath, signal, onProgress }: UploadParams) => {
const url = await getPreSignedURL(destinationPath);
const xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('Content-Type', 'text/plain');
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = (event.loaded / event.total) * 100;
console.log(progress);
onProgress(progress);
}
};
xhr.send(file);
signal.addEventListener('abort', () => {
xhr.abort();
});
};

… and preSignedURL function to generate the cloudfront presigned URL needed to upload the file into s3. This function has to run on the server-side in order to keep the secrets safe.

'use server'
import {PutObjectCommand, S3Client} from "@aws-sdk/client-s3";
import {getSignedUrl} from '@aws-sdk/s3-request-presigner';

const s3Client = new S3Client({
region: process.env.BUCKET_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});

export const getPreSignedURL = async (key: string) => {
const command = new PutObjectCommand({
Bucket: process.env.BUCKET_NAME!,
Key: key,
ContentType: 'text/plain',
});

return await getSignedUrl(s3Client, command, {expiresIn: 60})
};

This is how the component can be used in a form.

"use client"

import {Button} from "@/components/ui/button"
import {useForm} from "react-hook-form";
import {z} from "zod";
import {zodResolver} from "@hookform/resolvers/zod";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {DropzoneField} from "@/components/DropZone";
import React from "react";

export default function Home() {
const z_form = z.object({
file: z.array(z.instanceof(File)), // z.instanceof(File) for single file and z.array(z.instanceof(File)) for multiple files
// file: z.instanceof(File),
filePaths: z.record(z.string(), z.string()),
})
const form = useForm<z.infer<typeof z_form>>({
resolver: zodResolver(z_form),
defaultValues: {
file: undefined,
filePaths: {},
}
});

const onSubmit = () => {
console.log(form.getValues())
}

return (
<div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="file"
render={({field}) => (
<FormItem>
<FormLabel>Files</FormLabel>
<FormControl>
<DropzoneField
multiple={true}
filePathName="filePaths"
destinationPathPrefix="uploads"
description="Any additional information can be added here"
{...field}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
<div className="flex flex-col space-y-2 mt-4">
{Object.entries(form.getValues().filePaths).map(([name, path]) => (
<div key={name} className="flex items-center space-x-2">
<span>{name}</span>
<span>{path}</span>
</div>
))}
</div>
</div>
)
}

Finally, you would need the secrets to be stored in the .env.local file. The .env.example file looks like this:

AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
BUCKET_REGION=
BUCKET_NAME=
CLOUDFRONT_DOMAIN=
CLOUDFRONT_KEY_ID=

You can also find the entire repository here

--

--

No responses yet