useDownload
Headless React hooks for presigned downloads — useDownload hands the URL to the browser, useFetchDownload fetches the bytes with progress.
Two hooks download objects from a named route:
useDownloadpresigns a GET URL and hands it to the browser, which saves or opens the file. No bytes pass through your page.useFetchDownloadpresigns, then fetches the bytes in the page with progress and cancel, and saves them.
Both track each key on its own. Several keys can download at once, and a second call for a key that is already running joins that call.
"use client";
import { useDownload } from "@dimah-s3/react";
export function DownloadReport({ objectKey }: { objectKey: string }) {
const { download, get } = useDownload({ route: "reports" });
const isPending = get(objectKey)?.phase === "presigning";
return (
<button
type="button"
onClick={() => void download(objectKey, { fileName: "report.pdf" })}
disabled={isPending}
>
{isPending ? "Preparing…" : "Download"}
</button>
);
}For ready-made buttons, pass either return value to the download components.
useDownload
download(key, { fileName }) presigns the key, then clicks a temporary link
to the URL. The response's Content-Disposition decides whether the browser
saves the file or opens it. disposition on the hook asks for inline or
attachment; the server can override it.
const { download } = useDownload({
route: "reports",
disposition: "attachment",
onSuccess: (data, key) => {
console.log("Browser has the URL for", key, data.url);
},
});
const outcome = await download(objectKey);
if (outcome.status === "error") console.log(outcome.error.code);onSuccess runs once the browser has the URL, not when the file finishes
saving.
Presign without downloading
presign(key, options?) returns the signed URL without handing it to the
browser, for example to copy a link. presignMany(files) signs several keys,
four at a time. Both run beforeDownload, but they record no operation and
run no other callback.
const outcome = await presign(objectKey, {
fileName: "report.pdf",
expiresIn: 900, // seconds; the server caps it at the route's limit
});
if (outcome.status === "success") {
await navigator.clipboard.writeText(outcome.data.url);
}
const { signed, failed, blocked } = await presignMany([
{ key: firstKey },
{ key: secondKey, expiresIn: 3600 },
]);useFetchDownload
useFetchDownload fetches the bytes in the page and saves them as a file. It
reports progress and can be cancelled.
"use client";
import { useFetchDownload } from "@dimah-s3/react";
export function FetchDownload({ objectKey }: { objectKey: string }) {
const { download, cancel, get } = useFetchDownload({ route: "documents" });
const operation = get(objectKey);
const running =
operation?.phase === "presigning" || operation?.phase === "downloading";
return running ? (
<button type="button" onClick={() => cancel(objectKey)}>
Cancel ({operation.progress.percent}%)
</button>
) : (
<button type="button" onClick={() => void download(objectKey)}>
Download
</button>
);
}The saved name is the fileName you pass, then the response's
Content-Disposition file name, then the last segment of the key. Pass
fileSize when you know it, so progress has a total before the response
arrives:
await download(objectKey, { fileName: "video.mp4", fileSize: 52_428_800 });onDownloadStart(key) runs when the transfer begins and
onProgress(progress, key) as bytes arrive. On success, data is
{ fileName, size }.
A cancelled download ends with an aborted outcome, not an error.
Operations and outcomes
Each download call records an operation. Read the newest one for a key
with get(key), or render operations:
| Field | useDownload | useFetchDownload |
|---|---|---|
phase | presigning, success, error, aborted | presigning, downloading, success, error, aborted |
error | APIError or null | APIError or null |
| extra | result: the signed URL response | progress, fileSize, and the saved fileName |
pendingKeys lists the keys with a running call, and isPending is true
while there is one. cancel(key?) aborts one key or every key, and
reset(key?) removes settled operations.
download resolves with an outcome and does not throw:
const outcome = await download(objectKey);
switch (outcome.status) {
case "success":
break; // outcome.data
case "error":
break; // outcome.error is an APIError
case "blocked":
break; // beforeDownload returned false
case "aborted":
break; // cancel(key) ran
}beforeDownload(key) runs once per call. Return false to block it; a throw
fails it with HOOK_FAILED. onError(error, key) runs when a call fails, and
onSettled(outcome) once per call, however it ended. A call that joins a
running call does not run the callbacks again.
Accept either hook
Each return has a mode: "navigate" for useDownload, "fetch" for
useFetchDownload. Type a prop as AnyDownloadReturn and narrow on mode:
import type { AnyDownloadReturn } from "@dimah-s3/react";
function progressOf(download: AnyDownloadReturn, key: string) {
if (download.mode === "fetch") return download.get(key)?.progress.percent;
return undefined;
}Type reference
import type {
AnyDownloadReturn,
DownloadOperation,
FetchDownloadOperation,
UseDownloadOptions,
UseDownloadReturn,
UseFetchDownloadOptions,
UseFetchDownloadReturn,
} from "@dimah-s3/react";UseDownloadOptions
Prop
Type
UseDownloadReturn
Prop
Type
DownloadOperation
Prop
Type
UseFetchDownloadOptions
Prop
Type
UseFetchDownloadReturn
Prop
Type
FetchDownloadOperation
Prop
Type
Frequently asked questions
Use useDownload by default. The browser streams the file to disk and your
page holds no bytes. Use useFetchDownload when you need progress or
cancel, and the file fits in memory.
Yes. Each key has its own operation, so render each button from get(key).
It fetches the signed URL with plain fetch. On a route with
download.mode: "proxy", the /file request sends same-origin cookies but
not the headers set on createS3Client. Use cookie auth for proxy routes.
Use useObjectUrl. It signs an inline URL,
caches it, and refreshes it before it expires.