@lcabrera/ui
v0.2.0
Published
React 19 component library built around a virtualized, filterable data table. StyleX-styled, React Router framework mode.
Maintainers
Readme
@lcabrera/ui
A React 19 component library built around one hard problem: a data table that stays responsive with tens of thousands of rows — virtualized, filterable, sortable, column-pinnable, resizable, with server-driven filter options and infinite scroll. Everything else in the package grew out of making that work.
Styled exclusively with StyleX, built for React Router in framework mode, and designed so that re-renders are governed by granular store subscriptions rather than by memoization.
Install
npm install @lcabrera/uiPeer dependencies
Framework singletons are peers on purpose — as ordinary dependencies your app would resolve a second copy of React, which breaks hooks outright.
npm install react react-dom react-router @stylexjs/stylex| Peer | Range | Notes |
| -------------------- | --------- | ------------------------------------------------------ |
| react | ^19.0.0 | Uses use(), Actions, and the React Compiler |
| react-dom | ^19.0.0 | |
| react-router | ^8.0.0 | Framework mode — loaders, actions, useLoaderData |
| @stylexjs/stylex | ^0.19.0 | |
| @react-router/node | ^8.0.0 | Optional — only the ./entry/* SSR helpers use it |
@react-router/node is an optional peer so a browser-only consumer is not forced
to install it.
Before you install: this package ships TypeScript source
@lcabrera/ui is published as source, not as a compiled bundle — unlike its
three sibling packages, which ship .mjs + .d.mts. StyleX resolves its style
definitions at build time, so the styles have to be compiled in your build,
against your theme, to be themeable and to dedupe with your own styles.
That means your bundler must:
- Compile TypeScript/JSX from
node_modules/@lcabrera/ui— most setups excludenode_modulesfrom transpilation by default. - Run the StyleX plugin over the package source, with an alias so StyleX can resolve the package's internal imports.
With Vite:
import { unplugin as stylex } from '@stylexjs/unplugin';
import { fileURLToPath } from 'node:url';
import babel from 'vite-plugin-babel';
const uiSrc = fileURLToPath(
new URL('node_modules/@lcabrera/ui/src/', import.meta.url),
);
export default {
plugins: [
stylex.vite({
aliases: { '@lcabrera/ui/*': [`${uiSrc}*`] },
useCSSLayers: true,
}),
babel({
babelConfig: {
parserOpts: { plugins: ['jsx'] },
plugins: [['babel-plugin-react-compiler']],
presets: [['@babel/preset-typescript', { ignoreExtensions: true }]],
},
include: /@lcabrera\/ui\/src\/(?!.*\.test\.).*\.[jt]sx?(\?.*)?$/,
}),
],
};If that is more build surface than you want, take the package as a reference
implementation rather than a dependency — the source is the documentation, and
every component carries an ARCHITECTURE.md.
What's in it
Import from the root barrel for the handful of top-level pieces, or from a subpath for anything else. There is deliberately no single mega-barrel: a subpath import is what keeps an unused component out of your bundle.
import { Button, Form, TableLayout } from '@lcabrera/ui';
import { Modal } from '@lcabrera/ui/components/Modal';
import { useVirtualization } from '@lcabrera/ui/hooks';A component subpath resolves through that directory's index.ts, so it needs a
bundler's directory resolution — which this package requires in any case, per the
section above.
Root barrel — @lcabrera/ui
AppDocument, AppProviders, AppShell, Button, Form, JsonExplorer,
NavLink, RootErrorBoundary, RouteErrorBoundary, SectionCard,
StatusBadge, TableLayout, Tabs, hydrateApp, useNotifyOnError, plus the
FieldNode, LayoutProps and Pagination types.
Components — @lcabrera/ui/components/*
| Area | Components |
| -------------- | -------------------------------------------------------------------------------------------------------------------- |
| Data | Table, StaticTable, VirtualList, VirtualSelect, TrendSparkline, JsonExplorer |
| Forms | Form, Checkbox, RadioOptionGroup, ToggleSwitch, DraggableList |
| Overlays | Modal, ChoiceModal, ConfirmDialog, SidePanel, PinSideModal, Tooltip, NotificationCenter |
| Layout | AppShell, AppDocument, AppProviders, AppBackground, AppNavigation, Navbar, Card, Tabs |
| Primitives | Button, ActionButtons, CopyButton, Icons, InfoBox, NavLink, SectionCard, StatusBadge, Tag, Title |
| Feedback | RootErrorBoundary, RouteErrorBoundary, MarkdownRenderer, Settings |
Hooks — @lcabrera/ui/hooks
useBackNavigate, useClickOutside, useElementSize, useInfiniteScrollObserver,
useNotifyOnError, usePersistCookieAction, useResizeObserver, useStore,
useVirtualization.
Everything else
| Subpath | What it holds |
| ------------------------------ | ------------------------------------------------------------------------- |
| @lcabrera/ui/design-system/* | StyleX tokens, light/dark themes, a CSS reset |
| @lcabrera/ui/contexts/* | Global settings, notifications, theme — each a store-pattern provider |
| @lcabrera/ui/routing/* | Loader/action helpers: URL state, cookie persistence, filter sanitisation |
| @lcabrera/ui/utils/* | Filters, prefetch, storage, theme, URL state, a logger |
| @lcabrera/ui/types/* | Shared type definitions |
| @lcabrera/ui/entry/* | SSR entry helpers (needs @react-router/node) |
| @lcabrera/ui/server | createHandleRequest — the SSR request handler |
Usage
A full data table
TableLayout is the assembled table: header, virtualized body, settings and
column drawers, filters, sorting, pinning, infinite scroll. You give it a promise
and tell it how to read rows out of the response.
import type { Pagination } from '@lcabrera/ui';
import { TableLayout } from '@lcabrera/ui';
import { useLoaderData } from 'react-router';
export const Orders = () => {
const { columnsState, ordersPromise, metaState } =
useLoaderData<typeof loader>();
const handleLoadMore = async ({ limit, skip }: Pagination) =>
fetchOrdersPage({ limit, skip });
return (
<TableLayout<Order, OrdersResponse>
columnsState={columnsState}
dataPromise={ordersPromise}
dataSelector={(response) => response.data}
dataTotalSelector={(response) => response.total}
metaState={metaState}
onLoadMore={handleLoadMore}
/>
);
};dataPromise is passed unresolved — the table suspends on it, so the shell,
header and skeleton stream to the browser before the rows exist. onLoadMore is
what infinite scroll calls as you approach the end of the loaded range.
The app shell
AppShell renders the navigation chrome and the routed outlet; it takes the
navigation items from your app rather than owning them. AppProviders supplies
theme and global-settings state, seeded from the root loader so SSR and the
browser agree on the first paint.
import { AppProviders, AppShell } from '@lcabrera/ui';
import { useLoaderData } from 'react-router';
export const Root = () => {
const { globalSettings, theme } = useLoaderData<typeof rootLoader>();
return (
<AppProviders
appId='my-app'
defaultTheme='light'
globalSettings={globalSettings}
initialTheme={theme}
>
<AppShell getNavigationItems={getNavigationItems} />
</AppProviders>
);
};appId scopes the theme and settings cookies, so two apps on the same host do
not overwrite each other's preferences.
Virtualization on its own
The table's virtualization is available as a hook if that is the only part you want. It measures the container itself and re-measures on resize, so you supply the item height and total count and render the window it hands back.
import { useVirtualization } from '@lcabrera/ui/hooks';
const { bottomSpacerHeight, endIndex, offsetY, startIndex, totalHeight } =
useVirtualization({
containerRef,
itemHeight: 36,
overscan: 8,
totalItems: rows.length,
});How it is built
Four decisions explain most of the code, and they are worth knowing before you extend anything:
- Split contexts + external stores, not prop drilling. Table state lives in
useSyncExternalStore-backed stores split by concern, and components subscribe through selectors. A cell that depends on one column's width re-renders when that width changes — not when any table state changes. - The React Compiler owns memoization. There is almost no
useMemoormemo()here by design. Performance comes from the subscription granularity above, from row virtualization, and from streaming — not from hand-tuning. - StyleX only. No CSS modules, no styled-components, no inline styles, no
Tailwind. Styles are colocated in
*.stylex.tsfiles and composed in a fixed order so overrides are predictable. - Client-safe by construction. A publish gate fails the build if anything in
this package's dependency closure reaches for
node:*. That is why the HTTP helpers live in@lcabrera/apiand the Postgres code in@lcabrera/server, rather than all three sharing one package.
Every component directory carries an ARCHITECTURE.md covering its props, render
flow and constraints; src/PATTERNS.md covers the conventions across all of
them, and src/INVENTORY.md catalogues every artifact.
Links
- Repository —
packages/ui - Changelog
- Component patterns · Artifact inventory
- Companion packages:
@lcabrera/utils(pure helpers),@lcabrera/api(browser HTTP),@lcabrera/server(Node + Postgres)
MIT © Lucio Cabrera
