Skip to content

Instantly share code, notes, and snippets.

@isocroft
Last active May 14, 2025 09:31
Show Gist options
  • Save isocroft/230d71269926cc218d6a120063a1ac81 to your computer and use it in GitHub Desktop.
Save isocroft/230d71269926cc218d6a120063a1ac81 to your computer and use it in GitHub Desktop.
A ReactJS hook for setting up a resumable file upload using a tus client + a tus server
import { useEffect, useCallback, useRef, useState, useMemo } from "react";
import { Upload } from "tus-js-client";
enum UploadStatus {
IDLE = "idle",
UPLOADING = "uploading",
PAUSED = "paused",
RESUMED = "resumed",
COMPLETED = "completed",
ERROR = "error",
}
const useResumableFileUploader = ({
uploadEndpointUrl,
onBeforeUpload,
onProgressUpdate,
onErrorRaised,
onSuccessReached
}: {
uploadEndpointUrl: string,
onBeforeUpload<F extends File>(variable: F): void,
onProgressUpdate: (progress: number) => void,
onErrorRaised: (error: { message: string }) => void,
onSuccessReached<D = unknown>(data: D): void
}) => {
const uploadRef = useRef<Upload | null>(null);
const [uploadStatus, setUploadStatus] = useState<UploadStatus>(UploadStatus.IDLE);
const statuses = useMemo(() => {
const isUploadIdle = uploadStatus === UploadStatus.IDLE;
const canResetUpload = uploadStatus === UploadStatus.COMPLETED || uploadStatus === UploadStatus.ERROR || uploadStatus === UploadStatus.PAUSED;
const canResumeUpload = uploadStatus === UploadStatus.PAUSED;
const isUploadActive = uploadStatus === UploadStatus.UPLOADING || uploadStatus === UploadStatus.RESUMED;
const canAbortUpload = uploadStatus !== UploadStatus.ERROR && uploadStatus !== UploadStatus.PAUSED && uploadStatus !== UploadStatus.COMPLETED && uploadStatus !== UploadStatus.IDLE;
const canStartUpload = uploadStatus !== UploadStatus.UPLOADING && uploadStatus !== UploadStatus.PAUSED && uploadStatus !== UploadStatus.RESUMED;
return {
isUploadIdle,
canAbortUpload,
canPauseUpload: canAbortUpload,
isUploadActive,
canResumeUpload,
canResetUpload,
canStartUpload
} as const;
},[uploadStatus]);
const resetUploadStatus = useCallback(() => {
if (uploadStatus === UploadStatus.COMPLETED || uploadStatus === UploadStatus.ERROR || uploadStatus === UploadStatus.PAUSED) {
if (uploadStatus !== UploadStatus.PAUSED && Boolean(uploadRef.current)) {
uploadRef.current = null;
}
setUploadStatus(UploadStatus.IDLE);
}
}, [uploadStatus]);
useEffect(() => {
const onProgressChange = (e: CustomEventInit<{ progress: number }>) => {
onProgressUpdate(e.detail.progress);
};
const onErrorFound = (e: CustomEventInit<{ error: { message: string } }>) => {
onErrorRaised(e.detail.error);
};
const onSuccess = (e: CustomEventInit<unknown>) => {
onSuccessReached(e.detail);
};
window.addEventListener("_progress.change", onProgressChange, false);
window.addEventListener("_error.found", onErrorFound, false);
window.addEventListener("_success", onSuccess, false);
return () => {
window.removeEventListener("_progress.change", onProgressChange, false);
window.removeEventListener("_error.found", onErrorFound, false);
window.removeEventListener("_success", onSuccess, false);
};
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [uploadEndpointUrl]);
const startFileUpload = useCallback(function<F extends File> (
file: F,
{
uploadChunkSize = 5 * 1024 * 1024,
uploadRetryDelays = [0, 3000, 6000, 9000]
}: { uploadChunkSize: number, uploadRetryDelays: number[] }
) {
uploadRef.current = uploadRef.current || new Upload(file, {
endpoint: uploadEndpointUrl,
retryDelays: uploadRetryDelays,
chunkSize: uploadChunkSize,
metadata: {
fileName: file.name,
fileType: file.type,
},
onProgress: (totalByteSend, totalSize) => {
const progress = Math.round((totalByteSend / totalSize) * 100));
window.dispatchEvent(new CustomEvent("_progress.change", {
detail: { progress }
}));
},
onError: (error) => {
window.dispatchEvent(new CustomEvent("_error.found", {
detail: { error }
}));
setUploadStatus(UploadStatus.ERROR);
},
onSuccess: (data) => {
uploadRef.current = null;
window.dispatchEvent(new CustomEvent("_success", {
detail: data
}));
setUploadStatus(UploadStatus.COMPLETED);
},
});
if (statuses.isUploadIdle) {
uploadRef.current.start();
setUploadStatus(UploadStatus.UPLOADING);
}
}, [uploadEndpointUrl, statuses.isUploadIdle]);
const pauseFileUpload = useCallback(() => {
if (!uploadRef.current || !statuses.canPauseUpload) {
return;
}
uploadRef.current.abort();
setUploadStatus(UploadStatus.PAUSED);
}, [statuses.canPauseUpload]);
const abortFileUpload = useCallback(() => {
if (!uploadRef.current || !statuses.canPauseUpload) {
return;
}
uploadRef.current.abort();
window.dispatchEvent(new CustomEvent("_success", {
detail: null
}));
setUploadStatus(UploadStatus.COMPLETED);
}, [statuses.canPauseUpload]);
const resumeFileUpload = useCallback(() => {
if (!uploadRef.current || !statuses.canResumeUpload) {
return;
}
const previousUploadsPromise = uploadRef.current.findPreviousUploads();
return previousUploadsPromise.then((previousUploads) => {
uploadRef.current.resumeFromPreviousUpload(previousUploads[0]);
uploadRef.current.start();
setUploadStatus(UploadStatus.RESUMED);
});
}, [statuses.canResumeUpload]);
return {
statuses,
resetUploadStatus,
resumeFileUpload,
abortFileUpload,
pauseFileUpload,
startFileUpload
} as const
};
@isocroft
Copy link
Author

isocroft commented May 3, 2025

import React from "react";

import { FileUploader } from "@/components/file-upload";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";



import { toast } from "sonner";
import { useResumableFileUploader } from '@/lib/utils';

const useMediaFileUploader = ({ url }: { url: string }) => {
   const [file, setFile] = useState<File | undefined>(undefined);
    const [progress, setProgress] = useState<number>(0);
    const { resetUploadStatus, startFileUpload, statuses, ...controls } = useResumableFileUploader({
      uploadEndpointUrl: url,
      onProgressUpdate: (progress) => {
        setProgress(progress);
      },
      onErrorRaised: (error) => {
        toast.error(error.message);
      },
      onSuccessReached: () => {
        setFile(undefined);
        setProgress(0);
      }
   });
   
   const registerFileForUpload = useCallback(($file) => {
      if (file.name === $file.name 
        && file.type === $file.type
          && file.size === $file.size) {
        return;
      }
      
      if (!$file.type.startsWith('image/') || !$file.type.startsWith('video/')) {
        toast.warning("File type must either be an image or video");
        return;
      }
      
      if ($file.size / 1_000_000_000 >= 1) {
        toast.warning("File size must be less than 10 GB");
        return;
      }
      
      if (statuses.canResetUpload) {
        resetUploadStatus();
      }
      setFile($file);
   }, [statuses.canResetUpload, file]);
   
   const beginFileUpload = () => {
     if (!statuses.isUploadIdle || progress > 0) {
        toast.warning("Cannot begin file upload while already in progress");
        return;
     }
     
     if (!file) {
       toast.warning("Please attach a file for upload first");
       return;
     }
     
     startFileUpload(file);
   };
   
   return {
     fileUploadStatuses: statuses,
     registerFileForUpload,
     ...controls,
     resetUploadStatus,
     beginFileUpload,
     fileUploadProgress: progress
   };
}

const FileUpload = () => {
    const { 
      fileUploadStatuses,
      fileUploadProgress,
      resumeFileUpload, 
      registerFileForUpload,
      resetUploadStatus,
      pauseFileUpload,
      beginFileUpload
    } = useMediaFileUploader({
       url: `${import.meta.env.VITE_APP_API_URI}/files`
    });
   
    return (
        <div className="flex flex-col gap-4 justify-center items-center">
            <div className="flex w-full items-center gap-2">
                {fileUploadProgress ? <Progress value={fileUploadProgress} /> : null}
                <h3 className="text-green-700">{fileUploadProgress ? `${fileUploadProgress}%` : ""}</h3>
            </div>
            
            <FileUploader 
                onFileSelected={registerFileForUpload}
                resetOn={fileUploadStatuses.isUploadIdle}
             />
             
            <div className="flex justify-center items-center gap-4">
                <Button
                    disabled={!fileUploadStatuses.canResumeUpload}
                    onClick={resumeFileUpload}
                    className="cursor-pointer bg-[#22C55E]"
                >
                    Resume
                </Button>
                <Button
                    disabled={!fileUploadStatuses.canStartUpload}
                    onClick={beginFileUpload}
                    className="cursor-pointer"
                >
                    Upload
                </Button>
                <Button
                    disabled={!fileUploadStatuses.canPauseUpload}
                    onClick={pauseFileUpload}
                    className="cursor-pointer bg-[#EA580C]"
                >
                    Pause
                </Button>
                
               {/* Added a reset button below 👇🏾👇🏾 */}
               <Button
                   disabled={!fileUploadStatuses.canResetUpload}
                   onClick={resetUploadStatus}
                  className="cursor-pointer bg-[#EEFCAA]"
               >
                  Reset
              </Button>
            </div>
        </div>
    );
}

@isocroft
Copy link
Author

isocroft commented May 13, 2025

import { useEffect, useCallback, useState, useMemo } from "react";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";

import type { Axios, AxiosRequestConfig, AxiosResponse } from "axios";

enum UploadStatus {
  IDLE = "idle",
  UPLOADING = "loading",
  COMPLETED = "success",
  ERROR = "error",
}

const prepareFormDataPayloadWithUploadPreset = (file: File, { uploadPreset, formDataFileKey }) => {
  const $UPLOAD_PRESET = typeof uploadPreset === "string" && uploadPreset.length > 0
    ? uploadPreset
    : process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET!
    
   const formData = new FormData();
   formData.append(formDataFileKey, file);
   formData.append(
    "upload_preset",
    $UPLOAD_PRESET
  );
  return formData;
}

export const getUploadServerClients = (data: FormData, url?: string | URL) => {
   const $url = Boolean(url) && url instanceof URL
     ? url.toString()
     : url || process.env.NEXT_PUBLIC_APP_URL!
     
   return {
     axiosClient: <T, D = FormData>(axios: Axios, reqConfig: AxiosRequestConfig<D>) => {
       if (!(data instanceof FormData)) {
         throw new Error("request body is not of type `FormData`");
       }
       return axios.post<T, AxiosResponse<T, D>, D>(url, data, reqConfig);
     },
     fetchClient: <T, D = FormData>(fetch: ((url: string | URL, init: RequestInit) => Promise<Response>), reqConfig: Omit<RequestInit, "method" | "body">) => {
       if (!(data instanceof FormData)) {
         throw new Error("request body is not of type `FormData`");
       }
       reqConfig.method = "POST";
       reqConfig.body = data as D;
       return fetch($url, reqConfig);
     }
  };
};

export const getCloudinaryClients = (data: FormData, url?: string | URL, options?: { CLOUD_NAME: string }) => {
  const $CLOUD_NAME = options instanceof Object && typeof options.CLOUD_NAME === "string" && options.CLOUD_NAME.length > 0
    ? CLOUD_NAME
    : process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME!
  const $url = Boolean(url) && url instanceof URL
    ? url.toString()
    : url || `https://api.cloudinary.com/v1_1/${$CLOUD_NAME}/image/upload`;
    
   return {
     axiosClient: <T, D = FormData>(axios: Axios, reqConfig: AxiosRequestConfig<D>) => {
       if (!(data instanceof FormData)) {
         throw new Error("request body is not of type `FormData`");
       }
       return axios.post<T, AxiosResponse<T, D>, D>($url, data, reqConfig);
     },
     fetchClient: <T, D = FormData>(fetch: ((url: string | URL, init: RequestInit) => Promise<Response>), reqConfig: Omit<RequestInit, "method" | "body">) => {
       if (!(data instanceof FormData)) {
         throw new Error("request body is not of type `FormData`");
       }
       reqConfig.method = "POST";
       reqConfig.body = data as D;
       return fetch($url, reqConfig);
     }
  };
};

export const useIrreversibleFileUploader = ({
  uploadEndpointUrl,
  onProgressUpdate,
  onBeforeUpload,
  onErrorRaised,
  onSuccessReached
}: {
  uploadEndpointUrl: string,
  onProgressUpdate: (progress: number) => void,
  onBeforeUpload<R = FormData>(variables: R): R,
  onErrorRaised: (error: { message: string }) => void,
  onSuccessReached<D = unknown>(data: D): void
}) => {
    const controllers: Record<string, AbortController> = useMemo(() => ({}), []);
    const { mutate, status: uploadStatus  } = useMutation<FormData>({
      mutationFn: (data: FormData) => {
        const _id = data.get("__abort-id_key");
        
        if (data.has("__abort-id_key")) {
          data.delete("__abort-id_key");
        }

        if (data.has("upload_preset")) {
          const { axiosClient } = getCloudinaryClients(data, uploadEndpointUrl);
          return axiosClient(axios, { 
            signal: Boolean(_id) ? controllers[_id].signal : undefined,
            onUploadProgress: (progressEvent: ProgressEvent) => {
              const percentCompleted = Math.round(
                (progressEvent.loaded * 100) / progressEvent.total!
              );
              
              const event = new CustomEvent("_progress.change", {
                detail: { progress: percentCompleted }
              });
              
              window.dispatchEvent(event);
            }
          });
        }

        const { axiosClient } = getUploadServerClients(data, uploadEndpointUrl);
        return axiosClient(axios, {
          signal: Boolean(_id) ? controllers[_id].signal : undefined,
          onUploadProgress: (progressEvent: ProgressEvent) => {
              const percentCompleted = Math.round(
                (progressEvent.loaded * 100) / progressEvent.total!
              );
              
              const event = new CustomEvent("_progress.change", {
                detail: { progress: percentCompleted }
              });
              
              window.dispatchEvent(event);
           }
        });
      },
      retry: 3,
      onMutate:onBeforeUpload,
      onSuccess: onSuccessReached,
      onError: onErrorRaised
    });
    const statuses = useMemo(() => {
      const isUploadIdle = uploadStatus === UploadStatus.IDLE;
      const canResetUpload = uploadStatus === UploadStatus.COMPLETED || uploadStatus === UploadStatus.ERROR;
      const isUploadActive = uploadStatus === UploadStatus.UPLOADING;
      const canAbortUpload = uploadStatus !== UploadStatus.ERROR && uploadStatus !== UploadStatus.COMPLETED && uploadStatus !== UploadStatus.IDLE;
      const canStartUpload = uploadStatus !== UploadStatus.UPLOADING;
  
      return {
        isUploadIdle,
        canResetUpload,
        isUploadActive,
        canAbortUpload,
        canStartUpload
      } as const;
  },[uploadStatus]);
  
  useEffect(() => {
    const onProgressChange = (e: CustomEventInit<{ progress: number }>) => {
      onProgressUpdate(e.detail.progress);
    };

    window.addEventListener("_progress.change", onProgressChange, false);
    
    return () => {
      window.removeEventListener("_progress.change", onProgressChange, false);
      controllers = {};
    };
    /* eslint-disable-next-line react-hooks/exhaustive-deps */
  }, [uploadEndpointUrl]);
  
  const startFileUpload = useCallback(function<F extends File>(file: F, options: {
    uploadPreset?: string,
    formDataFileKey: string,
    uploadAbortKey: string,
  }) {
     if (!(options instanceof Object)) {
       return;
     }
     
     let requestPayload = null;
     controllers[uploadAbortKey] = new AbortController();

     if (typeof options.uploadPreset === "string") {
        requestPayload = prepareFormDataPayloadWithUploadPreset(
          file,
          options
        );
     } else {
       const formData = new FormData();
       formData.append(options.formDataFileKey,  file);
       requestPayload = formData;
     }
     
     requestPayload.append("__abort-id_key",  uploadAbortKey);
     
     return mutate(requestPayload);
  }, [mutate]);

  const abortFileUpload = useCallback((uploadAbortKey: string) => {
    if (typeof uploadAbortKey !== "string") {
      throw new Error("`uploadAbortKey` is not a string");
    }
    const $controller: AbortController | undefined = controllers[uploadAbortKey];
    
    if ($controller) {
      $controller.abort();
      return true;
    }
    
    return false;
  }, []);
  
  return {
    statuses,
    startFileUpload,
    abortFileUpload,
  };
};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment