Reference
Subpath exports, diagnostics, keyboard shortcuts, saved view preferences, optimized images and the remaining options.
Diagnostics
listkit emits coded diagnostics so a broken config surfaces in development
instead of shipping. LK1xxx throw in dev (no-op in production); LK2xxx
warn once; LK3xxx are shown in the UI.
| Code | Severity | Meaning |
|---|---|---|
| LK1001 | error | Duplicate export field key in the universe. |
| LK1002 | error | Export requested with no export configuration. |
| LK1003 | error | Wire request named a field key outside the whitelist (dropped). |
| LK1004 | error | Sort on a path that resolves to an array — precompute a flat field. |
| LK2001 | warn | Export field produced a non-primitive; cell rendered empty. |
| LK2002 | warn | Cell exceeded Excel's 32,767-character limit; truncated. |
| LK2003 | warn | data: URI dropped from a cell (plain URLs pass). |
| LK2004 | warn | GET-encoded export request too large — switch the resolver to POST. |
| LK3001 | info | maxRows truncated the export — shown in the dialog ("N of M rows"). |
Empty state
"No results" means different things: a list nobody has written to yet wants an invitation, a filtered one wants a hint to loosen the filters. Compose it with empty, without replacing the layout:
import { PackageOpen } from 'lucide-react'
defineListConfig<Product>({
empty: {
title: 'No products yet',
message: 'Add your first product to see it here.',
icon: <PackageOpen size={40} />,
action: <button onClick={openCreate}>New product</button>,
},
})| Field | Effect |
|---|---|
title | Headline. Defaults to the active empty label. |
message | Supporting line under the title. |
icon | Custom glyph. null drops the icon block — denser for an embedded list. |
action | A button or link, so the empty screen has a next step. |
emptyMessage is the one-line shorthand and empty overrides it; renderEmpty replaces the whole block and is the last resort — reach for empty first so the spacing and theming stay consistent with the rest of the list.
Keyboard shortcuts
On by default. Every shortcut is bound by capability, not by state: a list with filters always answers +, whether or not any filter is applied right now — so the keys never move under your hands.
| Keys | Action |
|---|---|
⌘ K / Ctrl K | Focus the search box |
+ | Open the filters sidebar, armed on its search |
- | Remove the last applied filter |
Shift + C | Clear every filter |
Shift + V | Toggle table / cards |
Shift + D | Toggle compact / comfortable rows |
Shift + E | Open the configurable export |
Shift + R | Refresh the list |
Shift + A | Select the current page |
Esc | Clear the selection |
← / → | Previous / next page |
Shift + ← / Shift + → | First / last page |
? | Show this list, in an overlay |
? opens the help overlay, which lists only the shortcuts this list actually binds — it reads the same registry the handlers do, so it can never advertise a dead key. The ? hint also sits in the options menu for people who won't discover it by typing.
Shortcuts never fire while you're typing in an input, textarea or contenteditable, so - inside a search box stays a hyphen. Each one binds only when the list has the feature: no export config, no Shift + E, and the overlay doesn't list it.
Saved view preferences
Preferences that describe how a user works with a list persist per list id, so a list opens the way they left it. What is stored:
| Preference | Set from |
|---|---|
| Column order | Column manager, header drag |
| Hidden columns | Column manager |
| Column widths | Header resize |
| Density | Options menu |
| View (table/cards) | View toggle |
| Page size | Footer selector |
| Quick-filter bar | Options menu |
A URL param always wins over the stored value, so a shared link shows the sender's view, not the recipient's.
The view is the one preference the device can overrule: a narrow screen opens on cards whatever was stored — a table does not fit — and a toggle made there is not saved, so reaching for the columns on a phone never changes how the list opens on a desktop.
Storage is localStorage by default and pluggable — back it with your user-settings table to carry preferences across devices:
import type { ColumnStorage } from 'listkit'
const dbColumnStorage: ColumnStorage = {
get: key => cache.get(key) ?? null,
set: (key, prefs) => {
cache.set(key, prefs)
void api.saveColumnPrefs(key, prefs)
},
}
<ListView config={config} adapter={adapter} columnStorage={dbColumnStorage} />get/set are synchronous so the table paints the right columns on the first frame. To back an async store, hydrate a cache up front (from a server-rendered value or a one-time fetch), have get read that cache, and let set fire the write in the background — as above.
Optimized images (ListImage)
For dense tables/cards full of thumbnails, <ListImage> reserves its box (no layout shift), lazy-loads and async-decodes, shows a shimmer placeholder, and falls back on error:
import { ListImage } from 'listkit'
{ key: 'photo', header: '', exportable: false,
render: p => <ListImage src={p.photo} alt={p.name} width={40} height={40} /> }In Next.js, inject the optimized component — plain React falls back to <img>:
import Image from 'next/image'
;<ListImage as={Image} src={src} alt={alt} width={48} height={48} />Client-side compression belongs at the upload boundary (shrink before storing), not the render path — downloading a full image only to recompress it in JS makes rendering slower, not faster. Lazy-loading + framework optimization is what speeds up image-heavy lists.
Opening sort (defaultSort)
defineListConfig<Order>({
id: 'orders',
defaultSort: { field: 'placedAt', dir: 'desc' },
table: { columns: [{ key: 'placedAt', header: 'Date', sortable: true }] },
})The list opens sorted, the header shows the arrow, and clicking it cycles from there. A sort already in the URL wins, and clearing the sort is not re-applied until the next load. buildListQuery applies it server-side too, so an SSR seed matches the client's first query.
Subpath Exports
| Import path | Contents |
|---|---|
listkit | ListView, defineListConfig, ListKitProvider, ListSkeleton, invalidateListCache, useLabels, DEFAULT_LABELS/ES_LABELS, adapters, hooks, primitives, types |
listkit/next | useNextRouterAdapter, NextListView |
listkit/react-router | useReactRouterAdapter |
listkit/adapters | memoryAdapter, fetchAdapter, serverActionAdapter, createDexieAdapter |
listkit/server | buildListQuery, loadInitialList, defineListConfig, ListSkeleton — RSC-safe (no React/DOM) |
listkit/query | parseListkitQuery, parseSelectionDescriptor, filtersById, getString/getBoolean/getStringArray/getDateRange/getNumberRange/getText, paginate — parse a request bag into a ListQuery and read its filters |
listkit/sql | executeSqlList, buildSqlFilter, buildSearch, buildOrderBy, sqlPaginate, textCondition, sqlFieldMapFromFilters — Postgres query fragments + executor (pool injection, no driver dep) |
listkit/mongo | buildMongoQuery, buildMongoFilter, buildMongoSort, mongoPaginate, combineFilters, escapeRegex, mongoFieldMapFromFilters, filterConfigToMongoFieldMaps — MongoDB query objects (no driver) |
listkit/mongoose | executePaginatedListkitQuery, executeAggregateListkitQuery, castFilterToSchema, resolveSelectionFilter — runs the page query on Mongoose (optional, type-only mongoose peer dep) |
listkit/react-query | useReactQueryListData, invalidateList, listQueryKey — back lists with TanStack Query |
listkit/tailwind.css | Tailwind v4 source registration |
License
MIT © Ricardo Tapia