useObjectList
Headless hooks that page, search, sort, and select a folder catalog from any source, with uploads into folders and usage limits.
useObjectList keeps the open folder, the breadcrumb, cursor pages, search,
and sort. It does not know where the rows live and it does not call S3. You
pass a source: a query that loads one page, getKey for a stable row
id, and getFile to read a row's name, object key, size, and type.
"use client";
import { useObjectList } from "@dimah-s3/react";
type FileRow = { id: string; key: string; name: string; size: number };
export function FileBrowser() {
const list = useObjectList<FileRow>({
query: ({ parent, cursor, sort, search, signal }) =>
fetchFiles({ folderId: parent, cursor, sort, search, signal }),
getKey: (file) => file.id,
getFile: (file) => ({
name: file.name,
objectKey: file.key,
size: file.size,
}),
});
return (
<ul>
{list.entries.map((entry) => (
<li key={entry.key}>
{entry.kind === "folder" ? (
<button type="button" onClick={() => list.openFolder(entry.folder)}>
{entry.name}/
</button>
) : (
entry.name
)}
</li>
))}
</ul>
);
}entries lists folders, then files. Each has a key that is unique across
both kinds, a name, and either folder or item and file. items and
folders are there too when you want them apart.
Keep the source stable — define it outside the component, or in useState /
useMemo — so the entries are not rebuilt on every render. getFile should
be pure.
Query
The query receives:
parent— folder id, ornullat the root. The id is opaque: a database id, a path, or a key prefix.cursor—nullon the first page. ReturnnextCursor: nullon the last page.sort—{ field, direction }, ornullto leave the order to the source.search— trimmed text,""when nothing is searched.signal— aborted when the folder, sort, search, orqueryKeychanges, when the list reloads, or when the hook unmounts.
Return folders from the first page only. Later pages append items.
Changing the folder or queryKey clears the rows and shows the loading
state. Use queryKey for filters of your own, such as the route. Changing
the sort or search keeps the current rows on screen, with isRefreshing,
until the new first page arrives. Opening another folder clears the search.
A query that rejects sets status to error. error is an APIError; a
rejection that is not one becomes UNKNOWN_ERROR, with the original as its
cause.
Usage and limits
Return usage from a page when you know how full the catalog is:
return {
items,
nextCursor,
usage: {
totalBytes: 734_003_200,
objectCount: 120,
maxBytes: 1_073_741_824, // optional cap
maxFiles: 500, // optional cap
},
};list.usage keeps the totals from the latest page that sent them. A later
page may leave usage out; a first page that leaves it out clears it. The
caps are for display; the server still enforces its own quota.
Uploads and folders
A source can add two optional functions. The hook returns them as
list.uploadOptions and list.createFolder.
uploadOptions(parent)— the request options for files uploaded into a folder (nullis the root). It turns on drop-on-folder.createFolder({ parent, name })— creates an empty folder. It turns on "New folder".
const source: ObjectListSource<FileRow> = {
query,
getKey: (file) => file.id,
uploadOptions: (parent) => ({ metadata: { folder: parent ?? "" } }),
createFolder: async ({ parent, name }) => {
const folder = await api.createFolder({ parent, name });
return { id: folder.id, name: folder.name };
},
};list.createFolder(name) creates the folder inside the open folder and adds
it to the loaded folders. It rejects with an APIError. If the list moved to
another folder while the call ran, the new folder is not added.
With your own controls, pass the options to the upload hook. Drop targets use
getDropTargetProps, so folder rows keep their own click and keyboard
handlers:
const upload = useUpload({ route: "files", maxFiles: 20, queue: true });
<div
{...upload.getRootProps(undefined, () => ({
uploadOptions: list.uploadOptions?.(list.parent),
}))}
>
<input {...upload.getInputProps()} />
{list.folders.map((folder) => (
<button
key={folder.id}
type="button"
onClick={() => list.openFolder(folder)}
{...upload.getDropTargetProps(undefined, () => ({
uploadOptions: list.uploadOptions?.(folder.id),
}))}
>
{folder.name}
</button>
))}
</div>;A drop on a folder row inside the dropzone goes to that folder. The
ObjectList component does this
wiring for you when you pass it upload.
Sources
memoryObjectSource
memoryObjectSource from @dimah-s3/react/engine serves records you already
hold: folders from a /-separated path, then search, sort, and pages on the
client. Pass a function to read the latest records on each request.
import { useObjectList } from "@dimah-s3/react";
import { memoryObjectSource } from "@dimah-s3/react/engine";
const source = memoryObjectSource(files, {
getKey: (file) => file.id,
getFile: (file) => ({
name: file.name,
objectKey: file.key,
size: file.size,
updatedAt: file.updatedAt,
}),
getFolder: (file) => file.folder, // "Invoices/2026", or null for the root
pageSize: 50,
limits: { maxBytes: 1_073_741_824, maxFiles: 500 },
});
const list = useObjectList(source);usage is sent with each page when limits is set, or when you pass
usage: true. It totals every record, not only the open folder. Pass
usage: false to turn it off.
Search matches the name, ignoring case and accents; pass matches to change
that. Sort understands name, size, and updatedAt; pass sortValue for
other fields. Records without a value sort last.
readObjectListLevel is the folder step on its own: it splits a flat list
into the folders and files at one level. Pass the full set for that listing.
One page of a database query cannot be grouped after the fact.
dbObjectSource
dbObjectSource(api.db, options) from @dimah-s3/db/client lists the rows
of the db() plugin. By default it lists one folder at a time:
parent is a folder path, the first page carries the child folders, uploads
go to the open folder, and "New folder" calls api.db.createFolder. Pass
folders: false for a flat list with no folder actions.
"use client";
import { useState } from "react";
import { dbClient, dbObjectSource } from "@dimah-s3/db/client";
import {
createS3Client,
useDelete,
useDownload,
useObjectList,
useUpload,
} from "@dimah-s3/react";
import { ObjectList } from "@dimah-s3/ui";
const s3Client = createS3Client({
basePath: "/api/s3",
plugins: [dbClient()],
});
export function DbFiles() {
const api = s3Client.useApi();
const [source] = useState(() => dbObjectSource(api.db, { route: "files" }));
const list = useObjectList({ ...source, queryKey: ["files"] });
const upload = useUpload({ route: "files", maxFiles: 20, queue: true });
const download = useDownload({ route: "files" });
const remove = useDelete({ route: "files" });
return (
<ObjectList
list={list}
upload={upload}
download={download}
delete={remove}
/>
);
}Search and sort run on the server: the search text matches the filename,
name sorts by filename, updatedAt by last change, and size by verified
size. usage is the scope's total with its quota caps, not the open
folder's. Scope comes from the server resolveScope option; the query does
not accept one.
Navigation
Omit parent and the hook owns the folder. Pass parent and
onParentChange together when the URL owns it. parent={null} is the
controlled root. defaultTrail labels the starting path; its last id should
match defaultParent. sort / onSortChange and search /
onSearchChange work the same way, with defaultSort and defaultSearch
for uncontrolled starts.
const list = useObjectList({
...source,
parent: folderId,
onParentChange: setFolderId,
});openFolder, navigate(trail), and goUp() move between folders.
Loading and edits
loadMore()loads the next page. It is ignored while another request is in flight.refresh()reloads every page on screen and swaps them in at once.retry()repeats what failed: the next page, or the reload.statusisidle,loading,refreshing,loading-more,ready, orerror.isLoading,isRefreshing, andisLoadingMoremirror it.setItems(updater),setFolders(updater), andremoveItems(keys)edit the loaded rows without a request, for example after a rename. The next reload replaces them.
Selection
useObjectListSelection(list) adds checkbox state over the loaded entries.
It is headless: draw your own checkboxes, or pass it to ObjectList as
selection.
const selection = useObjectListSelection(list, {
mode: "multiple",
isSelectable: (entry) => entry.kind === "file",
onSelectionChange: (selected) => console.log(selected.length),
});
selection.toggle(entry.key, { range: event.shiftKey });
selection.selectAll();
selection.selectedItems; // your recordsBy default only files can be selected, and the selection clears when the
folder changes and drops entries a reload no longer returns. mode: "single"
keeps one entry. persist: true keeps entries from other folders, as in a
picker that collects files from several places.
UI
ObjectList renders the list and
the selection, with search, sort, a grid view, upload, folders, usage, and a
file menu. It does not call these hooks.
Props
import type {
ObjectListSource,
UseObjectListOptions,
UseObjectListReturn,
UseObjectListSelectionOptions,
UseObjectListSelectionReturn,
} from "@dimah-s3/react";Prop
Type
Prop
Type
File
Prop
Type
Usage
Prop
Type
memoryObjectSource
Prop
Type
Selection
Prop
Type
Prop
Type