- Introduction
- Pitch
- Hogwarts
- Live Demo
- MVP
- Roadmap
- Launch Sprint
- PRD
- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
Finance
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Playwright
- Prettier
- Block Rebound
Sales & GTM
table.tsx and columns.tsx are client leaves rendered by client.tsx. Both are always "use client" — TanStack Table requires hooks and cells use JSX. Data is fetched on the server and passed in as props.
Categories — columns.tsx
| Category | Shape | Reference |
|---|---|---|
| Factory with callbacks (newer, preferred) | get<Entity>Columns(dictionary, lang, callbacks) | listings/teachers/columns.tsx |
Factory with useModal in cells (legacy) | Hook inside cell renderer (anti-pattern) | listings/students/columns.tsx |
| Static array | const <entity>Columns: ColumnDef<RowType>[] | saas-dashboard/domains/columns.tsx |
Categories — table.tsx
| Category | Shape | Reference |
|---|---|---|
| DataTable + load-more | useDataTable with paginationMode="load-more" | saas-dashboard/domains/table.tsx |
| DataTable + pagination | URL-based pagination via nuqs | listings/teachers/table.tsx |
Raw shadcn <Table> | Simple non-interactive displays | billing/invoice-table.tsx |
Rules
| # | Rule |
|---|---|
| 1 | "use client" for both files |
| 2 | Row types in types.ts, never inline in columns.tsx |
| 3 | Use callback pattern for actions — never call hooks inside cell renderers |
| 4 | useMemo for column generation in table.tsx |
| 5 | Use the shared DataTable + useDataTable from @/components/table/ |
| 6 | Stay under ~200 lines per file |
Canonical example — minimal table
"use client"
import { useMemo } from "react"
import { DataTable } from "@/components/table/data-table"
import { useDataTable } from "@/components/table/use-data-table"
import { catalogColumns, type CatalogSubjectRow } from "./columns"
interface Props {
data: CatalogSubjectRow[]
}
export function CatalogTable({ data }: Props) {
const columns = useMemo(() => catalogColumns, [])
const { table } = useDataTable<CatalogSubjectRow>({
data,
columns,
pageCount: 1,
initialState: {
pagination: { pageIndex: 0, pageSize: data.length || 50 },
},
})
return <DataTable table={table} />
}Callback pattern (avoid hooks in cells)
// columns.tsx
export interface TeacherColumnCallbacks {
onView?: (row: TeacherRow) => void
onEdit?: (row: TeacherRow) => void
onDelete?: (row: TeacherRow) => void
}
export const getTeacherColumns = (
dictionary?: Dictionary["school"]["teachers"],
lang?: Locale,
callbacks?: TeacherColumnCallbacks
): ColumnDef<TeacherRow>[] => [
{
id: "actions",
cell: ({ row }) => (
<Button onClick={() => callbacks?.onEdit?.(row.original)}>Edit</Button>
),
},
]
// table.tsx
const columns = useMemo(
() => getTeacherColumns(dictionary, lang, callbacks),
[dictionary, lang, callbacks]
)Load-more pattern
const handleLoadMore = useCallback(async () => {
if (isLoading || !hasMore) return
setIsLoading(true)
try {
const result = await getDomains({ page: nextPage, perPage })
if (result.success && result.data.length > 0) {
setData((prev) => [...prev, ...result.data])
setCurrentPage(nextPage)
}
} finally {
setIsLoading(false)
}
}, [currentPage, perPage, isLoading, hasMore])
return (
<DataTable
table={table}
paginationMode="load-more"
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={handleLoadMore}
>
<DataTableToolbar table={table} />
</DataTable>
)Column meta for toolbar
meta: {
label: "Status",
variant: "select", // "text" = search input, "select" = dropdown
placeholder: "Search...",
options: [
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
],
}URL state syncs via nuqs and createSearchParamsCache() — see List Params.
Anti-patterns
useModal()inside cell renderers — violates Rules of Hooks. Use callbacks.- Row type inlined in
columns.tsx— define intypes.tsinstead. - Monolithic
columns.tsxfiles (e.g.transaction-history/columns.tsx) — split concerns. receipt/table.tsxshadowing theDataTableimport — rename toReceiptTable.- Fetching inside
table.tsx— server fetches viacontent.tsx. Load-more is the only exception.
Naming
| Export | Pattern | Example |
|---|---|---|
| Static columns | <entity>Columns | domainColumns |
| Factory columns | get<Entity>Columns | getTeacherColumns |
| Callbacks | <Entity>ColumnCallbacks | TeacherColumnCallbacks |
| Row type | <Entity>Row | TeacherRow |
| Table component | <Entity>Table | DomainsTable |
File layout
src/components/<feature>/
types.ts # RowType
config.ts # Status variants, filter options
columns.tsx # ColumnDef[]
table.tsx # DataTable consumer
actions.ts # Server actions (load-more)
content.tsx # Server component (passes data)