Skip to content

Type-safe React table library with automatic relationship filtering, zero boilerplate, and end-to-end type safety from database to UI.

Notifications You must be signed in to change notification settings

Better-Tables/better-tables

Repository files navigation

Better Tables

Type-safe, database-agnostic table library for React with advanced filtering, sorting, and virtual scrolling. Stop writing boilerplate. Start shipping features.

TypeScript React License Contributions Welcome

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.


🎯 Why Better Tables?

Building complex data tables should be simple. Not a soul-crushing mix of useState hooks, prop drilling, and scattered utility functions.

The Problem

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

The Solution

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 Magic: Adapters + Relationships

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 throughout

No manual query building. No JOIN syntax to memorize. Just define your columns, and Better Tables handles the rest.


πŸš€ Quick Start

Installation

# Core package
bun add @better-tables/core

# Choose an adapter
bun add @better-tables/adapters-drizzle  # or @better-tables/adapters-rest

Your First Table

// 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.


πŸ’Ž Key Features

Automatic Relationship Filtering

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.

Advanced Filtering System

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]

Database Adapters

Connect to any backend with a consistent API. No vendor lock-in.

Drizzle Adapter

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,
});

REST Adapter

import { RestAdapter } from '@better-tables/adapters-rest';

const adapter = new RestAdapter({
  baseUrl: '/api/users',
  headers: { Authorization: `Bearer ${token}` },
});

Virtual Scrolling for Large Datasets

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]

URL State Persistence

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]

Declarative Column Configuration

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]


πŸ—οΈ Architecture

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

Package Overview

  • @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

How Automatic Relationships Work

The Drizzle adapter uses sophisticated relationship detection:

  1. Schema Introspection: Analyzes your Drizzle schema to find relationships
  2. Relationship Mapping: Automatically maps one-to-one, one-to-many, and many-to-many relationships
  3. Query Generation: Builds optimized JOIN queries based on accessed columns
  4. Filter Translation: Converts UI filters into SQL WHERE clauses across joined tables
  5. Type Safety: Maintains TypeScript types throughout the entire query chain

When you reference user.profile.location in a column, the adapter:

  • Detects the user β†’ profile relationship
  • 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.


πŸ“– Documentation

Package Documentation

Quick Links


🎨 Examples

πŸ’‘ Tip: Check out the complete demo app for a full working example with all features!

Cross-Table Filtering (The Magic Feature)

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]

Filtering with Multiple Types

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]

Custom Cell Rendering

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]


🀝 Contributing

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.

How to Contribute

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Make your changes
  4. Run tests (bun run test)
  5. Commit your changes (git commit -m 'Add some amazing feature')
  6. Push to the branch (git push origin feature/amazing-feature)
  7. Open a Pull Request

Areas We Need Help

  • 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.


πŸ›£οΈ Roadmap

Current Status (v0.5)

  • βœ… 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

Coming Next (v0.6+)

  • REST adapter
  • Export functionality (CSV, Excel)
  • Saved filter presets
  • Advanced column customization
  • Performance benchmarks and optimization
  • Enhanced documentation and examples

Future (v1.0)

  • 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

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

πŸ™ Acknowledgments

Better Tables is inspired by and built with:


πŸ“„ License

MIT License - see LICENSE for details.


πŸ’¬ Questions?

  • 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.

About

Type-safe React table library with automatic relationship filtering, zero boilerplate, and end-to-end type safety from database to UI.

Resources

Code of conduct

Security policy

Stars

Watchers

Forks

Contributors 3

  •  
  •  
  •  

Languages