Last active
May 14, 2025 09:31
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
}; |
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
Uh oh!
There was an error while loading. Please reload this page.