2.0 beta — new releases can include breaking changes. Stable 1.x docs
dimah-s3v2.0.0-beta.0

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.

components/file-browser.tsx
"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, or null at the root. The id is opaque: a database id, a path, or a key prefix.
  • cursor — null on the first page. Return nextCursor: null on the last page.
  • sort — { field, direction }, or null to leave the order to the source.
  • search — trimmed text, "" when nothing is searched.
  • signal — aborted when the folder, sort, search, or queryKey changes, 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 (null is 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.

components/db-files.tsx
"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.

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.
  • status is idle, loading, refreshing, loading-more, ready, or error. isLoading, isRefreshing, and isLoadingMore mirror it.
  • setItems(updater), setFolders(updater), and removeItems(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 records

By 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

On this page