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.

CodeSeverityMeaning
LK1001errorDuplicate export field key in the universe.
LK1002errorExport requested with no export configuration.
LK1003errorWire request named a field key outside the whitelist (dropped).
LK1004errorSort on a path that resolves to an array — precompute a flat field.
LK2001warnExport field produced a non-primitive; cell rendered empty.
LK2002warnCell exceeded Excel's 32,767-character limit; truncated.
LK2003warndata: URI dropped from a cell (plain URLs pass).
LK2004warnGET-encoded export request too large — switch the resolver to POST.
LK3001infomaxRows 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>,
	},
})
FieldEffect
titleHeadline. Defaults to the active empty label.
messageSupporting line under the title.
iconCustom glyph. null drops the icon block — denser for an embedded list.
actionA 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.

KeysAction
⌘ K / Ctrl KFocus the search box
+Open the filters sidebar, armed on its search
-Remove the last applied filter
Shift + CClear every filter
Shift + VToggle table / cards
Shift + DToggle compact / comfortable rows
Shift + EOpen the configurable export
Shift + RRefresh the list
Shift + ASelect the current page
EscClear 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:

PreferenceSet from
Column orderColumn manager, header drag
Hidden columnsColumn manager
Column widthsHeader resize
DensityOptions menu
View (table/cards)View toggle
Page sizeFooter selector
Quick-filter barOptions 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 pathContents
listkitListView, defineListConfig, ListKitProvider, ListSkeleton, invalidateListCache, useLabels, DEFAULT_LABELS/ES_LABELS, adapters, hooks, primitives, types
listkit/nextuseNextRouterAdapter, NextListView
listkit/react-routeruseReactRouterAdapter
listkit/adaptersmemoryAdapter, fetchAdapter, serverActionAdapter, createDexieAdapter
listkit/serverbuildListQuery, loadInitialList, defineListConfig, ListSkeleton — RSC-safe (no React/DOM)
listkit/queryparseListkitQuery, parseSelectionDescriptor, filtersById, getString/getBoolean/getStringArray/getDateRange/getNumberRange/getText, paginate — parse a request bag into a ListQuery and read its filters
listkit/sqlexecuteSqlList, buildSqlFilter, buildSearch, buildOrderBy, sqlPaginate, textCondition, sqlFieldMapFromFilters — Postgres query fragments + executor (pool injection, no driver dep)
listkit/mongobuildMongoQuery, buildMongoFilter, buildMongoSort, mongoPaginate, combineFilters, escapeRegex, mongoFieldMapFromFilters, filterConfigToMongoFieldMaps — MongoDB query objects (no driver)
listkit/mongooseexecutePaginatedListkitQuery, executeAggregateListkitQuery, castFilterToSchema, resolveSelectionFilter — runs the page query on Mongoose (optional, type-only mongoose peer dep)
listkit/react-queryuseReactQueryListData, invalidateList, listQueryKey — back lists with TanStack Query
listkit/tailwind.cssTailwind v4 source registration

License

MIT © Ricardo Tapia

On this page