useDelete
Headless React hook that deletes one or many objects, with state for each key.
useDelete deletes objects through your server. Each key has its own state,
so a list can show which rows are being deleted. A second call for a key that
is already being deleted joins that call.
"use client";
import { useDelete } from "@dimah-s3/react";
export function DeleteFile({ objectKey }: { objectKey: string }) {
const { remove, get } = useDelete({ route: "attachments" });
const operation = get(objectKey);
return (
<button
type="button"
onClick={() => void remove(objectKey)}
disabled={operation?.phase === "deleting"}
>
{operation?.phase === "deleting" ? "Deleting…" : "Delete"}
</button>
);
}The hook has no confirm state. To ask first, use the ui
Delete root, which owns one confirm
dialog for every trigger inside it, or open your own dialog and call
remove when the user confirms.
Delete one key
remove(key, { fileName }) resolves with an outcome and does not throw.
fileName is stored on the operation for display, such as in a toast.
const outcome = await remove(objectKey, { fileName: "invoice.pdf" });
if (outcome.status === "success") console.log("Deleted", outcome.data.key);
if (outcome.status === "error") console.log(outcome.error.code);
if (outcome.status === "blocked") console.log("beforeDelete said no");A delete has no cancel, so it never ends aborted.
Delete many keys
removeMany(items) takes any number of keys. The hook sends them in batches
of up to 100 (DELETE_MANY_MAX_KEYS), one request after another, and every
operation of the call shares one batchId. The server deletes each key on its
own, so some can fail while others succeed:
const { deleted, failed, blocked } = await removeMany([
"attachments/a.pdf",
{ key: "attachments/b.pdf", fileName: "b.pdf" },
]);
for (const { key, error } of failed) console.log(key, error.code);Keys that are already being deleted are joined rather than sent again, and their outcomes are folded into the result.
State
operations— every delete still kept, in the order it started. Each haskey,fileName,batchId,phase(deleting,success, orerror), anderror.get(key)— the newest operation for a key.pendingKeys— keys with a running delete.isPending—truewhile any key is being deleted.reset(key?)— removes settled operations for one key, or every settled operation. It does not cancel a running delete.
Callbacks
| Callback | Runs |
|---|---|
beforeDelete(keys) | Once per call. Return false to block the keys. A throw fails them (HOOK_FAILED). |
onDeleteStart(keys) | Once per call, before the request. |
onError(error, key) | Once per failed key. |
onSuccess(data, key) | Once per deleted key, after every onError of the call. |
onSettled(result) | Once per call, last. |
data is { key, bucket? }. bucket is set by remove only, because batch
responses do not carry it. A call that joins a running delete does not run the
callbacks again.
Type reference
import type {
DeleteOperation,
DeleteResult,
UseDeleteOptions,
UseDeleteReturn,
} from "@dimah-s3/react";UseDeleteOptions
Prop
Type
UseDeleteReturn
Prop
Type
DeleteOperation
Prop
Type
DeleteResult
Prop
Type
Frequently asked questions
Check ownership on the server in delete.guard. Client checks are not an
authorization boundary. The route's keyPrefix only keeps keys inside the
route's folder; it does not know who owns a file. See
Security.
import { route, s3Error } from "@dimah-s3/server";
export const attachments = route({
delete: {
guard: async ({ key, request }) => {
const session = await getSession(request);
if (!session) throw s3Error("UNAUTHORIZED");
if (!(await isOwner(session.userId, key))) throw s3Error("FORBIDDEN");
},
},
});Call reset(key). Render the error from get(key)?.error:
const { get, reset } = useDelete({ route: "attachments" });
const error = get(objectKey)?.error;
if (error) {
return (
<button type="button" onClick={() => reset(objectKey)}>
{error.message} — dismiss
</button>
);
}