Uploader Provider
The Uploader Provider is a context provider that provides the uploader context to the components. You can use it as a base for creating your own components.
If you are installing the other dropzone components via the CLI, this component will be installed automatically. You can skip the following steps.
Installation
- CLI
- Manual
- pnpm
- npm
- yarn
- bun
bash
pnpm dlx shadcn@latest add https://edgestore.dev/r/uploader-provider.json
bash
npx shadcn@latest add https://edgestore.dev/r/uploader-provider.json
bash
npx shadcn@latest add https://edgestore.dev/r/uploader-provider.json
shell
bunx --bun shadcn@latest add https://edgestore.dev/r/uploader-provider.json
Copy this component
tsx
import * as React from 'react';/*** Represents the possible statuses of a file in the uploader.*/export type FileStatus = 'PENDING' | 'UPLOADING' | 'COMPLETE' | 'ERROR';/*** Represents the state of a file in the uploader.*/export type FileState = {/** The file object being uploaded */file: File;/** Unique identifier for the file */key: string;/** Upload progress (0-100) */progress: number;/** Current status of the file */status: FileStatus;/** URL of the uploaded file (available when status is COMPLETE) */url?: string;/** Error message if the upload failed */error?: string;/** AbortController to cancel the upload */abortController?: AbortController;/** Whether the file should be automatically uploaded */autoUpload?: boolean;};/*** Represents a file that has completed uploading.*/export type CompletedFileState = Omit<FileState, 'status' | 'url'> & {/** Status is guaranteed to be 'COMPLETE' */status: 'COMPLETE';/** URL is guaranteed to be available */url: string;};/*** Function type for handling file uploads.*/export type UploadFn<TOptions = unknown> = (props: {/** The file to be uploaded */file: File;/** AbortSignal to cancel the upload */signal: AbortSignal;/** Callback to update progress */onProgressChange: (progress: number) => void | Promise<void>;/** Additional options */options?: TOptions;}) => Promise<{ url: string }>;/*** Context type for the UploaderProvider.*/type UploaderContextType<TOptions = unknown> = {/** List of all files in the uploader */fileStates: FileState[];/** Add files to the uploader */addFiles: (files: File[]) => void;/** Update a file's state */updateFileState: (key: string, changes: Partial<FileState>) => void;/** Remove a file from the uploader */removeFile: (key: string) => void;/** Cancel an ongoing upload */cancelUpload: (key: string) => void;/** Start uploading files */uploadFiles: (keysToUpload?: string[], options?: TOptions) => Promise<void>;/** Reset all files */resetFiles: () => void;/** Whether any file is currently uploading */isUploading: boolean;/** Whether files should be automatically uploaded */autoUpload?: boolean;};/*** Props for the UploaderProvider component.*/type ProviderProps<TOptions = unknown> = {/** React children or render function */children:| React.ReactNode| ((context: UploaderContextType<TOptions>) => React.ReactNode);/** Callback when files change */onChange?: (args: {allFiles: FileState[];completedFiles: CompletedFileState[];}) => void | Promise<void>;/** Callback when a file is added */onFileAdded?: (file: FileState) => void | Promise<void>;/** Callback when a file is removed */onFileRemoved?: (key: string) => void | Promise<void>;/** Callback when a file upload completes */onUploadCompleted?: (file: CompletedFileState) => void | Promise<void>;/** Function to handle the actual upload */uploadFn: UploadFn<TOptions>;/** External value to control the file states */value?: FileState[];/** Whether files should be automatically uploaded when added */autoUpload?: boolean;};// Contextconst UploaderContext =React.createContext<UploaderContextType<unknown> | null>(null);/*** Hook to access the uploader context.** @returns The uploader context* @throws Error if used outside of UploaderProvider** @example* ```tsx* const { fileStates, addFiles, uploadFiles } = useUploader();* ```*/export function useUploader<TOptions = unknown>() {const context = React.useContext(UploaderContext);if (!context) {throw new Error('useUploader must be used within a UploaderProvider');}return context as UploaderContextType<TOptions>;}/*** Provider component for file upload functionality.** @component* @example* ```tsx* <UploaderProvider* uploadFn={async ({ file, signal, onProgressChange }) => {* // Upload implementation* return { url: 'https://example.com/uploads/image.jpg' };* }}* autoUpload={true}* >* <ImageUploader maxFiles={5} maxSize={1024 * 1024 * 2} />* </UploaderProvider>* ```*/export function UploaderProvider<TOptions = unknown>({children,onChange,onFileAdded,onFileRemoved,onUploadCompleted,uploadFn,value: externalValue,autoUpload = false,}: ProviderProps<TOptions>) {const [fileStates, setFileStates] = React.useState<FileState[]>(externalValue ?? [],);const [pendingAutoUploadKeys, setPendingAutoUploadKeys] = React.useState<string[] | null>(null);// Sync with external value if providedReact.useEffect(() => {if (externalValue) {setFileStates(externalValue);}}, [externalValue]);const updateFileState = React.useCallback((key: string, changes: Partial<FileState>) => {setFileStates((prevStates) => {return prevStates.map((fileState) => {if (fileState.key === key) {return { ...fileState, ...changes };}return fileState;});});},[],);const uploadFiles = React.useCallback(async (keysToUpload?: string[], options?: TOptions) => {const filesToUpload = fileStates.filter((fileState) =>fileState.status === 'PENDING' &&(!keysToUpload || keysToUpload.includes(fileState.key)),);if (filesToUpload.length === 0) return;await Promise.all(filesToUpload.map(async (fileState) => {try {const abortController = new AbortController();updateFileState(fileState.key, {abortController,status: 'UPLOADING',progress: 0,});const uploadResult = await uploadFn({file: fileState.file,signal: abortController.signal,onProgressChange: (progress) => {updateFileState(fileState.key, { progress });},options,});// Wait a bit to show the bar at 100%await new Promise((resolve) => setTimeout(resolve, 500));const completedFile = {...fileState,status: 'COMPLETE' as const,progress: 100,url: uploadResult?.url,};updateFileState(fileState.key, {status: 'COMPLETE',progress: 100,url: uploadResult?.url,});// Call onUploadCompleted when a file upload is completedif (onUploadCompleted) {void onUploadCompleted(completedFile);}} catch (err: unknown) {if (err instanceof Error &&// if using with EdgeStore, the error name is UploadAbortedError(err.name === 'AbortError' || err.name === 'UploadAbortedError')) {updateFileState(fileState.key, {status: 'PENDING',progress: 0,error: 'Upload canceled',});} else {if (process.env.NODE_ENV === 'development') {console.error(err);}const errorMessage =err instanceof Error ? err.message : 'Upload failed';updateFileState(fileState.key, {status: 'ERROR',error: errorMessage,});}}}),);},[fileStates, updateFileState, uploadFn, onUploadCompleted],);const addFiles = React.useCallback((files: File[]) => {const newFileStates = files.map<FileState>((file) => ({file,key: `${file.name}-${Date.now()}-${Math.random().toString(36).slice(2)}`,progress: 0,status: 'PENDING',autoUpload,}));setFileStates((prev) => [...prev, ...newFileStates]);// Call onFileAdded for each new fileif (onFileAdded) {newFileStates.forEach((fileState) => {void onFileAdded(fileState);});}if (autoUpload) {setPendingAutoUploadKeys(newFileStates.map((fs) => fs.key));}},[autoUpload, onFileAdded],);const removeFile = React.useCallback((key: string) => {setFileStates((prev) =>prev.filter((fileState) => fileState.key !== key),);// Call onFileRemoved when a file is removedif (onFileRemoved) {void onFileRemoved(key);}},[onFileRemoved],);const cancelUpload = React.useCallback((key: string) => {const fileState = fileStates.find((f) => f.key === key);if (fileState?.abortController && fileState.progress < 100) {fileState.abortController.abort();if (fileState?.autoUpload) {// Remove file if it was an auto-uploadremoveFile(key);} else {// If it was not an auto-upload, reset the file stateupdateFileState(key, { status: 'PENDING', progress: 0 });}}},[fileStates, updateFileState, removeFile],);const resetFiles = React.useCallback(() => {setFileStates([]);}, []);React.useEffect(() => {const completedFileStates = fileStates.filter((fs): fs is CompletedFileState => fs.status === 'COMPLETE' && !!fs.url,);void onChange?.({allFiles: fileStates,completedFiles: completedFileStates,});}, [fileStates, onChange]);// Handle auto-uploading files added to the queueReact.useEffect(() => {if (pendingAutoUploadKeys && pendingAutoUploadKeys.length > 0) {void uploadFiles(pendingAutoUploadKeys);setPendingAutoUploadKeys(null);}}, [pendingAutoUploadKeys, uploadFiles]);const isUploading = React.useMemo(() => fileStates.some((fs) => fs.status === 'UPLOADING'),[fileStates],);const value = React.useMemo(() => ({fileStates,addFiles,updateFileState,removeFile,cancelUpload,uploadFiles,resetFiles,isUploading,autoUpload,}),[fileStates,addFiles,updateFileState,removeFile,cancelUpload,uploadFiles,resetFiles,isUploading,autoUpload,],);return (<UploaderContext.Provider value={value as UploaderContextType<unknown>}>{typeof children === 'function' ? children(value) : children}</UploaderContext.Provider>);}/*** Formats a file size in bytes to a human-readable string.** @param bytes - The file size in bytes* @returns A formatted string (e.g., "1.5 MB")** @example* ```ts* formatFileSize(1024); // "1 KB"* formatFileSize(1024 * 1024 * 2.5); // "2.5 MB"* ```*/export function formatFileSize(bytes?: number) {if (!bytes) return '0 B';const k = 1024;const dm = 2;const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];const i = Math.floor(Math.log(bytes) / Math.log(k));return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;}
Usage
This section provides a step-by-step guide on how to use the UploaderProvider
and the useUploader
hook.
1. Setup <UploaderProvider>
Wrap the part of your application that needs uploader functionality with UploaderProvider
. You must provide an uploadFn
and can optionally configure autoUpload
.
uploadFn
: An asynchronous function that handles the actual file upload. It receives thefile
, anonProgressChange
callback, and anAbortSignal
. It should return an object with the uploaded file'surl
.autoUpload
: (Optional, default:false
) Iftrue
, files will start uploading immediately after being added.
tsx
import { UploaderProvider, UploadFn } from '@/components/ui/uploader'; // Adjust import pathimport { useEdgeStore } from '@/lib/edgestore'; // Adjust import pathimport * as React from 'react';function MyUploaderPage() {const { edgestore } = useEdgeStore();// Define the upload functionconst uploadFn: UploadFn = React.useCallback(async ({ file, onProgressChange, signal }) => {// Example using Edge Store clientconst res = await edgestore.publicFiles.upload({file,signal,onProgressChange,});// you can run some server action or api here// to add the necessary data to your databaseconsole.log('Upload successful:', res);return res; // Must return { url: string }},[edgestore],);return (// Provide the uploadFn and configure autoUpload<UploaderProvider uploadFn={uploadFn}>{/* Your uploader components go here */}<MyUploaderComponent /></UploaderProvider>);}// export default MyUploaderPage; // Assuming MyUploaderComponent is defined below
2. Use the useUploader
Hook
Inside components nested under UploaderProvider
, use the useUploader
hook to access the uploader's state and control functions.
tsx
import { useUploader } from '@/components/ui/uploader'; // Adjust import pathimport * as React from 'react';function MyUploaderComponent() {const {fileStates, // Array of current file statesaddFiles, // Function to add filesremoveFile, // Function to remove a file by keycancelUpload, // Function to cancel an upload by keyuploadFiles, // Function to trigger uploads (all pending or specific keys)isUploading, // Boolean indicating if any upload is in progress} = useUploader();// ... component logic using these values and functions ...return (<div>{/* UI elements */}</div>);}
3. Adding Files (addFiles
)
Typically, you'll use a standard file input. You might hide it and trigger its click event from a custom button. Get the selected File
objects from the input's onChange
event and pass them to addFiles
.
tsx
function MyUploaderComponent() {const { addFiles } = useUploader();const inputRef = React.useRef<HTMLInputElement>(null);// Handle file selection from the inputconst handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {if (e.target.files) {addFiles(Array.from(e.target.files));// Optional: Reset input value to allow selecting the same file againe.target.value = '';}};// Trigger the hidden input clickconst handleAddClick = () => {inputRef.current?.click();};return (<div>{/* Hidden file input */}<inputtype="file"ref={inputRef}onChange={handleFileChange}multiple // Allow multiple filesstyle={{ display: 'none' }}/>{/* Button to open file selector */}<button onClick={handleAddClick}>Add Files</button>{/* ... rest of the component ... */}</div>);}
4. Displaying File State (fileStates
)
The fileStates
array contains objects representing each file. Each object includes:
file
: The originalFile
object.key
: A unique string identifier.status
:'PENDING'
,'UPLOADING'
,'COMPLETE'
, or'ERROR'
.progress
: Upload progress (0-100).url
: (Optional) The URL after successful upload (status === 'COMPLETE'
).error
: (Optional) Error message if upload failed (status === 'ERROR'
).
Iterate over fileStates
to render the UI for each file.
tsx
function MyUploaderComponent() {const { fileStates, removeFile, cancelUpload } = useUploader();return (<div>{/* ... Add files button/input ... */}{/* List of files */}{fileStates.length > 0 && (<ul>{fileStates.map((fileState) => (<li key={fileState.key}><span>{fileState.file.name}</span><span> ({fileState.status})</span>{/* Show progress during upload */}{fileState.status === 'UPLOADING' && (<span> {fileState.progress}%</span>)}{/* Show cancel button during upload */}{fileState.status === 'UPLOADING' && (<button onClick={() => cancelUpload(fileState.key)}>Cancel</button>)}{/* Show remove button otherwise */}{fileState.status !== 'UPLOADING' && (<button onClick={() => removeFile(fileState.key)}>Remove</button>)}{/* Show error message */}{fileState.status === 'ERROR' && (<span style={{ color: 'red', marginLeft: '0.5rem' }}> Error: {fileState.error}</span>)}{/* Show link on completion */}{fileState.status === 'COMPLETE' && fileState.url && (<a href={fileState.url} target="_blank" rel="noopener noreferrer" style={{ color: 'green', marginLeft: '0.5rem' }}>View File</a>)}</li>))}</ul>)}</div>);}
5. Triggering Uploads (uploadFiles
)
Call uploadFiles()
to start uploading all files with status 'PENDING'
. You can optionally pass an array of specific file keys to uploadFiles(keysToUpload)
to upload only those files. Use the isUploading
boolean to disable the upload button during active uploads.
tsx
function MyUploaderComponent() {const { uploadFiles, isUploading, fileStates } = useUploader();// Check if there are any files pending uploadconst hasPendingFiles = fileStates.some(fs => fs.status === 'PENDING');return (<div>{/* ... Add files button/input and file list ... */}{/* Upload button */}<buttononClick={() => uploadFiles()} // Uploads all pending filesdisabled={isUploading || !hasPendingFiles} // Disable if uploading or no pending files>{isUploading ? 'Uploading...' : 'Upload All Pending'}</button></div>);}
6. Cancelling Uploads (cancelUpload
)
Call cancelUpload(key)
with the file's unique key to abort an ongoing upload. Your uploadFn
must be implemented to respect the AbortSignal
for cancellation to work correctly.
tsx
// Example within the file list rendering (see step 4){fileState.status === 'UPLOADING' && (<button onClick={() => cancelUpload(fileState.key)}>Cancel</button>)}
7. Removing Files (removeFile
)
Call removeFile(key)
with the file's key to remove it from the list, regardless of its status. If the file is currently uploading, this will also attempt to cancel the upload.
tsx
// Example within the file list rendering (see step 4){fileState.status !== 'UPLOADING' && (<button onClick={() => removeFile(fileState.key)}>Remove</button>)}
8. Callbacks
You can pass callback props (onChange
, onFileAdded
, onFileRemoved
, onUploadCompleted
) to the UploaderProvider
to execute logic when the uploader state changes.
tsx
<UploaderProvideruploadFn={uploadFn}onChange={({ allFiles, completedFiles }) => {console.log('Files changed:', allFiles);console.log('Completed files:', completedFiles);}}onFileAdded={(fileState) => console.log('File added:', fileState.file.name)}onUploadCompleted={(completedFile) => console.log('Upload complete:', completedFile.url)}>{/* ... */}</UploaderProvider>
Complete Component Example (MyUploaderComponent
)
Here is the MyUploaderComponent
combining the steps above:
tsx
import { useUploader } from '@/components/ui/uploader'; // Adjust import pathimport * as React from 'react';function MyUploaderComponent() {const {fileStates,addFiles,removeFile,cancelUpload,uploadFiles,isUploading,} = useUploader();const inputRef = React.useRef<HTMLInputElement>(null);// Function to handle file selectionconst handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {if (e.target.files) {addFiles(Array.from(e.target.files));e.target.value = ''; // Reset input}};// Function to trigger the hidden file inputconst handleAddClick = () => {inputRef.current?.click();};const hasPendingFiles = fileStates.some(fs => fs.status === 'PENDING');return (<div>{/* Hidden file input */}<inputtype="file"ref={inputRef}onChange={handleFileChange}multiple // Allow multiple file selectionstyle={{ display: 'none' }}/>{/* Buttons */}<button onClick={handleAddClick} disabled={isUploading}>Add Files</button><buttononClick={() => uploadFiles()}disabled={isUploading || !hasPendingFiles}>{isUploading ? 'Uploading...' : 'Upload All Pending'}</button>{/* Display file states */}{fileStates.length > 0 && (<ul style={{ listStyle: 'none', padding: 0, marginTop: '1rem' }}>{fileStates.map((fileState) => (<li key={fileState.key} style={{ marginBottom: '0.5rem', borderBottom: '1px solid #eee', paddingBottom: '0.5rem' }}><span>{fileState.file.name}</span><span style={{ marginLeft: '0.5rem', fontSize: '0.8em', color: '#666' }}> ({fileState.status})</span>{/* Progress and Controls */}<div style={{ marginTop: '0.25rem' }}>{fileState.status === 'UPLOADING' && (<><progress value={fileState.progress} max="100" style={{ width: '100px', marginRight: '0.5rem' }} /><span> {fileState.progress}%</span><button onClick={() => cancelUpload(fileState.key)} style={{ marginLeft: '0.5rem' }}>Cancel</button></>)}{fileState.status !== 'UPLOADING' && (<button onClick={() => removeFile(fileState.key)} style={{ marginLeft: '0.5rem' }}>Remove</button>)}{fileState.status === 'ERROR' && (<span style={{ color: 'red', marginLeft: '0.5rem' }}> Error: {fileState.error}</span>)}{fileState.status === 'COMPLETE' && fileState.url && (<a href={fileState.url} target="_blank" rel="noopener noreferrer" style={{ color: 'green', marginLeft: '0.5rem' }}>View File</a>)}</div></li>))}</ul>)}</div>);}
Putting It All Together (MyUploaderPage
)
Finally, use the MyUploaderComponent
within the page component wrapped by the UploaderProvider
.
tsx
import { UploaderProvider, UploadFn } from '@/components/ui/uploader'; // Adjust import pathimport { useEdgeStore } from '@/lib/edgestore'; // Adjust import pathimport * as React from 'react';// Assume MyUploaderComponent is defined in the same file or imported// import { MyUploaderComponent } from './MyUploaderComponent';function MyUploaderPage() {const { edgestore } = useEdgeStore();// Define the upload function (same as in step 1)const uploadFn: UploadFn = React.useCallback(async ({ file, onProgressChange, signal }) => {const res = await edgestore.publicFiles.upload({file,signal,onProgressChange,});console.log('Upload successful:', res);return res;},[edgestore],);return (<div><h1>My File Uploader</h1><UploaderProvideruploadFn={uploadFn}onUploadCompleted={(completedFile) => {console.log(`File ${completedFile.file.name} uploaded successfully to ${completedFile.url}`);// Maybe trigger a database update here}}><MyUploaderComponent /></UploaderProvider></div>);}export default MyUploaderPage;
This provides a basic but functional file uploader using the context provider. You can style the elements and integrate them further into your application's UI.