Type-safe, database-agnostic table library for React with advanced filtering, sorting, and virtual scrolling. Stop writing boilerplate. Start shipping features.
Better Tables is the React table library you wished existed. Define your columns once, and get powerful filtering, sorting, pagination, and virtualization-all with end-to-end type safety across your database queries and UI components.
Building complex data tables should be simple. Not a soul-crushing mix of useState hooks, prop drilling, and scattered utility functions.
Most table libraries ask you to:
- Wire up filtering logic across multiple files
- Manually handle joins and relationships in your queries
- Write the same filter UI components over and over
- Manually sync URL state for shareable views
- Give up type safety between your database and UI
- Rebuild pagination and sorting logic for every project
Better Tables revolutionizes how you work with relational data:
- Automatic Relationships: Filter across joined tables without writing JOIN queries yourself
- Database Adapters: Define your schema once-filters automatically work across relationships
- Type-Safe End-to-End: From your database query to your UI component, full type inference
- Zero Boilerplate: Declarative column definitions give you filtering, sorting, and pagination automatically
The real power comes from how Better Tables handles relationships automatically:
// You define columns that access related data
const columns = [
cb.text().id('name').accessor(u => u.name).build(),
// This automatically creates the JOIN and filters work on it!
cb.text().id('profile.location').accessor(u => u.profile?.location).build(),
cb.text().id('posts.title').accessor(u => u.posts?.[0]?.title).build(),
];
// The Drizzle adapter automatically:
// 1. Detects the relationships
// 2. Builds the JOIN queries
// 3. Applies filters across tables
// 4. Maintains type safety throughoutNo manual query building. No JOIN syntax to memorize. Just define your columns, and Better Tables handles the rest.
# Core package
bun add @better-tables/core
# Choose an adapter
bun add @better-tables/adapters-drizzle # or @better-tables/adapters-rest// TODO: UI package will a CLI and is on the roadmap
import { BetterTable } from '@better-tables/ui';
import { createColumnBuilder } from '@better-tables/core';
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
status: 'active' | 'inactive';
}
const cb = createColumnBuilder<User>();
const columns = [
cb.text().id('name').displayName('Name').accessor(u => u.name).build(),
cb.text().id('email').displayName('Email').accessor(u => u.email).build(),
cb.option().id('role').displayName('Role').accessor(u => u.role)
.options([
{ value: 'admin', label: 'Admin' },
{ value: 'editor', label: 'Editor' },
{ value: 'viewer', label: 'Viewer' },
]).build(),
];
function UserTable() {
return (
<BetterTable
columns={columns}
data={users}
features={{
filtering: true,
sorting: true,
pagination: true,
rowSelection: true,
}}
/>
);
}That's it. You now have a fully functional table with filtering, sorting, pagination, and row selection. No boilerplate, no prop drilling, no headaches.
The crown jewel of Better Tables: filter across relationships without writing JOIN queries.
// Define columns that touch multiple tables
const columns = [
cb.text().id('name').accessor(u => u.name).build(),
cb.text().id('profile.location').accessor(u => u.profile?.location).build(),
cb.number().id('posts.count').accessor(u => u.posts?.length || 0).build(),
];
// Filter by location - automatically creates the JOIN!
// SELECT users.*, profiles.location
// FROM users
// LEFT JOIN profiles ON profiles.user_id = users.id
// WHERE profiles.location = 'San Francisco'The adapter handles all the complexity: detecting relationships, building JOINs, applying filters across tables, and maintaining type safety throughout.
Six filter types with 20+ operators. Filters persist in the URL, making every view shareable.
Supported Filter Types:
- Text (contains, equals, startsWith, regex)
- Number (equals, greaterThan, between)
- Date (is, before, after, between)
- Option (is, isNot, isAnyOf)
- Multi-Option (includes, excludes)
- Boolean (isTrue, isFalse)
// Filters automatically work with your database adapter
<BetterTable
columns={columns}
data={users}
features={{ filtering: true }} // Full filter UI automatically included
/>[πΈ Screenshot: Filter UI with multiple filter types]
Connect to any backend with a consistent API. No vendor lock-in.
import { drizzleAdapter } from '@better-tables/adapters-drizzle';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import type { FilterState, SortingState } from '@better-tables/core';
// Set up your Drizzle database (schema and relations included)
const db = drizzle(sqlite, { schema: { users, profiles, usersRelations } });
// Create adapter - automatically detects schema and driver
const adapter = drizzleAdapter(db);
// Automatically handles joins, filtering, sorting, and pagination
const filters: FilterState[] = [
{ columnId: 'status', type: 'option', operator: 'is', values: ['active'] }
];
const sorting: SortingState = [{ columnId: 'name', direction: 'asc' }];
const result = await adapter.fetchData({
columns: ['name', 'email', 'status'],
pagination: { page: 1, limit: 20 },
filters,
sorting,
});import { RestAdapter } from '@better-tables/adapters-rest';
const adapter = new RestAdapter({
baseUrl: '/api/users',
headers: { Authorization: `Bearer ${token}` },
});Render millions of rows efficiently with built-in virtualization.
<VirtualizedTable
data={largeDataset}
columns={columns}
height={600}
rowHeight={52}
overscan={5}
/>[πΈ GIF: Smooth scrolling through 100k+ rows]
Every filter, sort, and pagination state syncs to the URL. Users can bookmark and share filtered views.
// URL: /users?page=2&filters=[{"columnId":"role","values":["admin"]}]
// Opening that URL loads the exact same filter and pagination state[πΈ Screenshot: Browser URL bar showing filter state]
Build complex tables with a fluent, type-safe API.
const columns = [
// Text column with search
cb.text()
.id('name')
.displayName('Full Name')
.accessor(user => `${user.firstName} ${user.lastName}`)
.searchable()
.sortable()
.build(),
// Option column with badges
cb.option()
.id('status')
.accessor(u => u.status)
.options([
{ value: 'active', label: 'Active', color: 'green' },
{ value: 'inactive', label: 'Inactive', color: 'red' },
])
.showBadges({ variant: 'default' })
.build(),
// Custom cell renderer
cb.text()
.id('actions')
.accessor(() => null)
.cellRenderer(({ row }) => (
<DropdownMenu>
<DropdownMenuItem onClick={() => editUser(row.id)}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={() => deleteUser(row.id)}>Delete</DropdownMenuItem>
</DropdownMenu>
))
.build(),
];[πΈ Screenshot: Table showing text search, option filters, and custom action cells]
Better Tables is built as a monorepo with clear separation of concerns:
better-tables/
βββ packages/
β βββ core/ # Type system, builders, managers
β βββ ui/ # React components & hooks
β βββ adapters/ # Database adapters
β βββ drizzle/ # Drizzle ORM integration
β β βββ relationship-detector.ts
β β βββ query-builder.ts # Automatic JOIN generation
β β βββ schema-inference.ts # Detect relationships
β βββ memory/ # In-memory adapter (testing)
β βββ rest/ # REST API adapter (coming soon)
βββ apps/
β βββ demo/ # Live demo application
βββ docs/ # Comprehensive documentation
- @better-tables/core - Type-safe builders, managers, and utilities
- @better-tables/ui - Production-ready React components with shadcn/ui
- @better-tables/adapters-drizzle - Automatic relationship detection and JOIN generation
- @better-tables/adapters-memory - In-memory adapter for testing and demos
The Drizzle adapter uses sophisticated relationship detection:
- Schema Introspection: Analyzes your Drizzle schema to find relationships
- Relationship Mapping: Automatically maps one-to-one, one-to-many, and many-to-many relationships
- Query Generation: Builds optimized JOIN queries based on accessed columns
- Filter Translation: Converts UI filters into SQL WHERE clauses across joined tables
- Type Safety: Maintains TypeScript types throughout the entire query chain
When you reference user.profile.location in a column, the adapter:
- Detects the
userβprofilerelationship - Identifies the foreign key
- Generates the appropriate JOIN
- Applies filters to the joined table
- Returns fully type-safe results
Each package is independently versioned and can be used standalone or together.
- Getting Started - Installation and basic setup
- User Guide - Complete feature reference
- Architecture - Design decisions and system overview
- API Reference - Complete API documentation
- Contributing - How to contribute to Better Tables
- @better-tables/core - Core package with builders and managers
- @better-tables/ui - React components and hooks
- @better-tables/adapters-drizzle - Drizzle ORM adapter
- Demo App - Complete working example
π‘ Tip: Check out the complete demo app for a full working example with all features!
Filter across relationships without writing SQL JOINs. This is what sets Better Tables apart.
import { drizzleAdapter } from '@better-tables/adapters-drizzle';
import { createColumnBuilder } from '@better-tables/core';
// Set up adapter
const db = drizzle(sqlite, { schema: { users, profiles, posts, usersRelations } });
const adapter = drizzleAdapter(db);
// Define columns that span multiple tables
const cb = createColumnBuilder<UserWithRelations>();
const columns = [
cb.text().id('name').accessor(u => u.name).build(),
cb.text().id('profile.location').accessor(u => u.profile?.location).build(),
cb.number().id('posts_count').accessor(u => u.posts?.length || 0).build(),
cb.text().id('profile.website').accessor(u => u.profile?.website).build(),
];
// User filters by "profile.location" - automatic JOIN generated
// User filters by "posts_count" - automatic COUNT and JOIN
// All handled by the adapter, zero query writing required
<BetterTable
columns={columns}
adapter={adapter}
features={{ filtering: true, sorting: true }}
/>The adapter automatically:
- Detects relationships from your schema
- Builds appropriate JOIN queries
- Applies filters across joined tables
- Handles pagination and sorting on joined data
- Maintains full type safety
[πΈ Screenshot: Table showing users with their profile location and post counts, with filters applied]
const columns = [
cb.text().id('name').accessor(u => u.name).searchable().build(),
cb.number().id('age').accessor(u => u.age).range(18, 100).build(),
cb.option().id('role').accessor(u => u.role).options([
{ value: 'admin', label: 'Admin' },
{ value: 'editor', label: 'Editor' },
]).build(),
cb.date().id('joined').accessor(u => u.joinedAt)
.dateRange({ includeNull: false }).build(),
];
// Automatically generates appropriate filter UIs for each type
<BetterTable columns={columns} data={users} features={{ filtering: true }} />[πΈ Screenshot: Filter bar showing text input, number range, select dropdown, and date picker]
cb.text()
.id('avatar')
.displayName('User')
.accessor(u => u.name)
.cellRenderer(({ value, row }) => (
<div className="flex items-center gap-2">
<img src={row.avatarUrl} alt={value} className="w-8 h-8 rounded-full" />
<span>{value}</span>
</div>
))
.build(),[πΈ Screenshot: Table row with custom avatar cell]
Better Tables is in active development, and we'd love your help! Whether you're fixing bugs, adding features, or improving docs, every contribution makes the library better.
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Make your changes
- Run tests (
bun run test) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
- Adapter Development: REST adapter implementation
- Examples: More real-world use cases
- Documentation: Better guides and tutorials
- Performance: Optimization for even larger datasets
- Accessibility: WCAG compliance improvements
See CONTRIBUTING.md for detailed guidelines.
- β Core type system and builders
- β Complete filter manager with 6 filter types
- β Drizzle adapter with automatic relationship detection
- β Factory function for easy adapter creation
- β UI components with shadcn/ui
- β Virtual scrolling support
- β URL state persistence
- β Server-side rendering support (Next.js)
- β Action builders for bulk operations
- β Primary table resolution for complex schemas
- REST adapter
- Export functionality (CSV, Excel)
- Saved filter presets
- Advanced column customization
- Performance benchmarks and optimization
- Enhanced documentation and examples
- GraphQL adapter
- Real-time updates via WebSockets
- Advanced analytics and aggregations
- Plugin system for custom features
- Official examples for Remix, Vite, CRA
- UI package CLI for component generation
| Package | Status | Description |
|---|---|---|
@better-tables/core |
β Ready | Core functionality and types |
@better-tables/ui |
β Ready | React components and hooks |
@better-tables/adapters-drizzle |
β Ready | Drizzle ORM integration |
@better-tables/adapters-memory |
β Ready | In-memory testing adapter |
@better-tables/adapters-rest |
π§ In Progress | REST API adapter |
@better-tables/pro |
π Planned | Premium features |
Better Tables is inspired by and built with:
- shadcn/ui - Beautiful, accessible components
- Drizzle ORM - Type-safe database queries
- Radix UI - Primitives for accessible components
- Zustand - Simple state management
MIT License - see LICENSE for details.
- GitHub Discussions - Ask questions and share ideas
- Issues - Report bugs or request features
- Contributing - Read our contribution guide
Built with β€οΈ by the Better Tables team.