Let’s face it—building an HTML table is simple enough if you stick to the good ol’ semantic approach with <table>
, <tbody>
, <tr>
, and <td>
. But what starts as an easy task can quickly turn into a nightmare when you find yourself building multiple tables scattered across different parts of your application. Suddenly, what seemed like a harmless chore morphs into a time-sucking beast.
Now imagine a feature request rolls in: "Hey, can we change the table header color? Oh, and bump up the font weight. Actually, let’s shuffle those columns while we’re at it." Sounds innocent enough, right? But then it hits you—you’ll need to go digging into every instance of every table to update them manually, praying you don’t accidentally break something in the process.
Yup. Told you. It can be a pain in the… well, let’s just say “backlog.”
But don’t worry—I’ve got a solution that’s more magical than a Hogwarts sorting hat! The secret? Reusable components. With the right mix of tools (think React, TypeScript, TailwindCSS, and a dash of Shadcn/UI), you can whip up a table component that’s versatile, efficient, and a joy to maintain. No more repetitive coding. No more chaos when requirements change.
Ready to save yourself hours of work and frustration? Let’s dive into the potions and spells—err, tools—you’ll need to create tables that work like magic.
Tools We’re Using
Alright, let’s talk about the secret weapons that’ll make this table component as slick as a well-oiled machine. We’re not just throwing random libraries together—we’re using a carefully curated stack that keeps things fast, flexible, and developer-friendly.
React.js (with Vite)
React’s component-based architecture is perfect for this job. Instead of copy-pasting the same table structure everywhere (which, let’s be honest, sounds like a nightmare), we’ll create a reusable table component that can be dropped into any part of our app with minimal effort. And why Vite? Because it’s blazing fast—seriously, you’ll wonder why you ever put up with slow-build tools.
Shadcn/UI
You could build table styles, buttons, and dropdowns from scratch… or you could just use Shadcn/UI and save yourself the hassle. This library gives us prebuilt, beautifully designed components that integrate seamlessly with TailwindCSS, so we can focus on making the table functional instead of spending hours tweaking styles.
TailwindCSS
Speaking of styling, TailwindCSS lets us write clean, maintainable styles without drowning in a sea of custom CSS files. Need to adjust spacing? Change font sizes? Update colors? Just tweak a class right inside the component. It’s like having a design system at your fingertips—without the headache.
TypeScript
Ever tried debugging an error caused by passing the wrong data to a component? Fun times. TypeScript helps us avoid that mess by enforcing strict type definitions. It makes sure our table rows, columns, and actions are properly structured, so we don’t end up with nasty runtime surprises. Plus, it gives us autocomplete for our table columns, rows, and data types, making development smoother and reducing guesswork. No more switching back and forth between files to remember what props a component expects—TypeScript has our back.
Visual Studio Code (VS Code)
Finally, every wizard needs a wand, and for us, that’s VS Code. It’s lightweight, powerful, and packed with extensions that’ll make writing and debugging code a breeze. With TypeScript IntelliSense, we get real-time autocomplete for our table structure, ensuring we never have to second-guess column names or row properties.
Installation & Setup
Step 1: Set Up a React + TypeScript Project
Run the following command
npm create vite@latest project-name
Then select Typescript
:
Once the project is created, navigate into the project directory and install dependencies:
cd project-name
npm install
Now, open the project in VS Code.
Step 2: Install & Configure Tailwind CSS (v4)
Run the command below to install tailwindcss into your project:
npm install tailwindcss @tailwindcss/vite
Next, add the @tailwindcss/vite
plugin to your Vite configuration. vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
});
Import Tailwind CSS to your index.css
@import "tailwindcss";
Step 3: Install Shadcn/UI
Edit tsconfig.json
file
The current version of Vite splits TypeScript configuration into three files, two of which need to be edited. Add the baseUrl
and paths
properties to the compilerOptions
section of the tsconfig.json
and tsconfig.app.json
files.
// tsconfig.json
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
Edit tsconfig.app.json
file
Add the following code to the tsconfig.app.json
file to resolve paths, for your IDE:
// tsconfig.app.json
{
"compilerOptions": {
// ...
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
// ...
}
}
Add the following code to the vite.config.ts so your app can resolve paths without error
npm install -D @types/node
// vite.config.ts
**import { defineConfig } from "vite";
import path from "path";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
Run the shadcn-ui
init command to setup your project:
npx shadcn@latest init
During setup, select "Slate" as the theme (or choose any other preferred theme).
To verify the installation, add a button component:
npx shadcn@latest add button
If everything works correctly, you should now have a Shadcn-styled button component ready to use!
Step 4: Install the Table Component
Since we’ll be building a table, install the Shadcn table component by running:
npx shadcn@latest add table
Final Check
At this point, your project is fully set up with React, TypeScript, TailwindCSS 4, and Shadcn/UI. If you run into issues, refer to the TailwindCSS Vite Guide or the Shadcn/UI documentation.
Now, let's start building our reusable table! 🚀
Building the Table: Where the Magic Happens 🪄
Step 1: Define the Table Component Interface
Create a CustomDynamicTable.tsx
component under the src/components directory and define your types
type StringKeys<T> = Extract<keyof T, string>
export interface CustomDynamicTableProps<T> {
tableData: T[]
tableColumns: StringKeys<T>[]
excludeColumns?: StringKeys<T>[]
className?: string
rowClassName?: string
customHeadRender?: (col: StringKeys<T>) => ReactNode | null | undefined
customBodyRender?: (rowData: T, col: StringKeys<T>) => ReactNode | null | undefined
onRowClick?: (rowData: T) => void
}
Why We Use Generics (T
)
Generics (T
) make our table component adaptable to any dataset while ensuring type safety. Instead of locking the table to a specific data type, T
acts as a flexible placeholder that adjusts based on the provided data.
The type alias StringKeys<T>
extracts only the string keys from the given type T
:
type StringKeys<T> = Extract<keyof T, string>;
This means that if our data rows (tableData
) have properties like name
or age
, only those keys can be included in tableColumns
.
🔒 No more errors from referencing a column key that doesn’t exist in the data—just a smooth, type-safe experience! 🚀
Even with this simple setup, TypeScript is already working its magic. Take a look at this array structure:
const employees: {
id: number;
name: string;
age: number;
department: string;
position: string;
salary: number;
email: string;
phone: string;
address: string;
isFullTime: boolean;
skills: string[];
manager: string;
}[];
Your table component should look like this now:
import { ReactNode } from "react";
type StringKeys<T> = Extract<keyof T, string>;
export interface CustomDynamicTableProps<T> {
tableData: T[]; // Array of table row data
tableColumns: StringKeys<T>[]; // Array of column keys to display
excludeColumns?: StringKeys<T>[];
className?: string;
rowClassName?: string | ((row: T) => string);
customHeadRender?: (col: StringKeys<T>) => ReactNode | null | undefined; // Custom render function for table headers
customBodyRender?: (
rowData: T,
col: StringKeys<T>
) => ReactNode | null | undefined; // Custom render function for table cells
onRowClick?: (rowData: T) => void; // Click handler for table rows
}
const CustomDynamicTable = <T extends object>(
props: CustomDynamicTableProps<T>
) => {
return <div></div>;
};
export default CustomDynamicTable;
Next, I’ll import the CustomDynamicTable.tsx
to our App.tsx
where I want to render our table component. Then I passed the employees
array structure to the tableData
. When defining the tableColumns
prop, TypeScript ensures that I can only select valid keys from the people
array’s structure.
As you can see in the screenshot below, the TypeScript integration provides a dropdown of all the valid keys for the tableColumns
prop, such as "name"
, "age"
, "address"
, and others. This constraint ensures that I can’t accidentally reference an invalid or non-existent property, making the component more robust, and reliable and significantly reducing potential bugs 🐞.
Building the Table UI:
Let’s start first by importing the ShadCN table component that we installed into the CustomDynamicTable.tsx
:
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
Let's initialize our table with Typescript Generics
const CustomDynamicTable = <T extends object>(
props: CustomDynamicTableProps<T>
) => {
-
<T extends object>
ensures that the component works with any structured data. - TypeScript enforces that
tableData
is an array of objects wheretableColumns
contain valid keys.
Adding helper functions
function shouldRender(key: Extract<keyof T, string>) {
if (excludeColumns.includes(key)) return false;
return true;
}
The shouldRender
function checks if a given column key should be displayed by ensuring it's not listed in the excludeColumns
array. It enforces type safety by only accepting string-based keys from the generic type T
, preventing invalid keys from being passed. This helps dynamically control which columns appear in the table without manually filtering them elsewhere.
Add this to your src/utils:
export function splitStringByUnderscore(str: string) {
return str.split("_").join(" ");
}
The splitStringByUnderscore
function converts snake_case strings into more readable text by replacing underscores with spaces, making it useful for formatting column headers or labels. I placed it in the utils folder because it’s a reusable, pure function that isn’t tied to any specific component. It keeps the codebase more organized and easy to reuse across the application.
Rendering Table Headers
<TableHeader>
<TableRow className="bg-muted">
{tableColumns.map((col) => {
return (
<Fragment key={col}>
{shouldRender(col) ? (
<TableHead
className={cn(
"whitespace-nowrap first-letter:capitalize"
)}
>
{whatToRenderHeader(col)}
</TableHead>
) : null}
</Fragment>
);
})}
</TableRow>
</TableHeader>
Add the whatToRenderHeader
function
const whatToRenderHeader = (col: StringKeys<T>) => {
if (!shouldRender(col)) return null;
const renderedContent = customHeadRender ? customHeadRender(col) : null;
return renderedContent || splitStringByUnderscore(col);
};
The whatToRenderHeader
function determines how each column header should be displayed by checking if it should be rendered, and then applying a custom render function (customHeadRender
) if provided. If no custom function is available, it defaults to splitStringByUnderscore
to format snake_case
column names into readable text. This function is used inside the table’s header mapping logic, ensuring only the necessary columns are displayed with properly formatted labels
Rendering Table Body & Cells
<TableBody>
{tableData.map((row, idx) => {
return (
<TableRow
key={idx}
className={cn(
typeof rowClassName === "function"
? rowClassName(row)
: rowClassName
)}
onClick={() => handleRowClick(row)}
>
{tableColumns.map((col) => {
return (
<Fragment key={col}>
{shouldRender(col) ? (
<TableCell className="whitespace-nowrap">
{whatToRenderBody(row, col)}
</TableCell>
) : null}
</Fragment>
);
})}
</TableRow>
);
})}
</TableBody>
Add the whatToRenderBody
function
const whatToRenderBody = (row: T, col: StringKeys<T>) => {
if (!shouldRender(col)) return null;
const renderedContent = customBodyRender
? customBodyRender(row, col)
: null;
return renderedContent || (row[col] as ReactNode);
};
Also, add the handleRowClick function for when a user clicks a row
// Memoized row click handler to prevent unnecessary re-renders
const handleRowClick = useCallback(
(row: T) => {
if (!onRowClick) return;
onRowClick(row);
},
[onRowClick]
);
The tableData.map
function loops through the dataset, creating a row for each entry. Inside each row, tableColumns.map
goes through the defined column keys to generate individual table cells. This makes the table dynamic, allowing it to display any dataset without hardcoding specific columns. The shouldRender(col)
check ensures that only the necessary columns are displayed, keeping things clean and flexible.
The rowClassName
prop gives you control over how each row looks. If you pass a string, it applies the same class to every row, keeping the styling consistent. But if you pass a function, it receives the row’s data and decides the class based on its content. This is useful when you need to highlight certain rows— For example, you could highlight employees in certain departments with different colors or mark part-time employees (isFullTime: false
) with a different background to distinguish them from full-time staff. This flexibility makes it easy to visually differentiate rows based on meaningful criteria
The whatToRenderBody
function takes two arguments the rol
and the col
which decides what should appear in each table cell. It first checks shouldRender(col)
to ensure the column is supposed to be displayed. Then, if a customBodyRender
function is provided, it lets you customize how the cell’s content is shown—like formatting dates or transforming values. If no custom rendering is needed, it just displays the raw data from row[col]
. This setup makes the table both flexible and easy to maintain.
The handleRowClick
function makes the rows interactive. Before doing anything, it checks if onRowClick
exists—if not, it simply does nothing. If an onRowClick
function is provided, it calls it with the current row’s data, allowing you to trigger actions like selecting a row or navigating to a details page.
Using useCallback
for handleRowClick
prevents unnecessary re-renders. Normally, functions are re-created every time a component re-renders, which can slow things down if passed to child components. Wrapping handleRowClick
in useCallback
ensures that it only gets re-created when onRowClick
changes, improving performance, especially in tables with lots of data.
The screenshot below shows the customBodyRender
in action and we get typescript autocomplete for each col
with an example usage of the rowClassName
as a function
that adds a class name based on the condition age ≤ 18
What have built so far
import { Fragment, ReactNode, useCallback } from "react";
import { cn, splitStringByUnderscore } from "@/lib/utils";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type StringKeys<T> = Extract<keyof T, string>;
export interface CustomDynamicTableProps<T> {
tableData: T[]; // Array of table row data
tableColumns: StringKeys<T>[]; // Array of column keys to display
excludeColumns?: StringKeys<T>[];
className?: string;
rowClassName?: string | ((row: T) => string);
customHeadRender?: (col: StringKeys<T>) => ReactNode | null | undefined; // Custom render function for table headers
customBodyRender?: (
rowData: T,
col: StringKeys<T>
) => ReactNode | null | undefined; // Custom render function for table cells
onRowClick?: (rowData: T) => void; // Click handler for table rows
}
const CustomDynamicTable = <T extends object>(
props: CustomDynamicTableProps<T>
) => {
const {
tableColumns,
tableData,
excludeColumns = [],
className,
rowClassName = "",
customHeadRender,
customBodyRender,
onRowClick,
} = props;
function shouldRender(key: StringKeys<T>) {
if (excludeColumns.includes(key)) return false;
return true;
}
const whatToRenderHeader = (col: StringKeys<T>) => {
if (!shouldRender(col)) return null;
const renderedContent = customHeadRender ? customHeadRender(col) : null;
return renderedContent || splitStringByUnderscore(col);
};
const whatToRenderBody = (row: T, col: StringKeys<T>) => {
if (!shouldRender(col)) return null;
const renderedContent = customBodyRender
? customBodyRender(row, col)
: null;
return renderedContent || (row[col] as ReactNode);
};
// Memoized row click handler to prevent unnecessary re-renders
const handleRowClick = useCallback(
(row: T) => {
if (!onRowClick) return;
onRowClick(row);
},
[onRowClick]
);
return (
<div className={cn("relative w-full overflow-auto", className)}>
<Table>
<TableHeader>
<TableRow className="bg-muted">
{tableColumns.map((col) => {
return (
<Fragment key={col}>
{shouldRender(col) ? (
<TableHead
className={cn(
"whitespace-nowrap first-letter:capitalize"
)}
>
{whatToRenderHeader(col)}
</TableHead>
) : null}
</Fragment>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{tableData.map((row, idx) => {
return (
<TableRow
key={idx}
className={cn(
typeof rowClassName === "function"
? rowClassName(row)
: rowClassName
)}
onClick={() => handleRowClick(row)}
>
{tableColumns.map((col) => {
return (
<Fragment key={col}>
{shouldRender(col) ? (
<TableCell className="whitespace-nowrap">
{whatToRenderBody(row, col)}
</TableCell>
) : null}
</Fragment>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
};
export default CustomDynamicTable;
Test cases:
1. Excluding Specific Columns
You can exclude certain columns from being displayed by using the excludeColumns
prop.
<CustomDynamicTable
tableData={employees}
tableColumns={["name", "age", "email", "address", "phone"]}
excludeColumns={["email", "phone"]} // These columns will be hidden
/>
Hides the "email"
and "phone"
columns while displaying the rest
2. Customizing Table Headers
<CustomDynamicTable
tableData={employees}
tableColumns={["name", "age", "email"]}
customHeadRender={(col) => {
if (col === "email") {
return (
<span className="text-blue-500 font-bold uppercase">
Email address
</span>
);
}
}}
/>
Targets the email header and changes it from "Email"
to "EMAIL ADDRESS"
and adds a blue text color.
3. Customizing Table Body Cells
<CustomDynamicTable
tableData={employees}
tableColumns={["name", "age", "email"]}
customBodyRender={(row, col) => {
if (col === "age") {
return <span className="text-green-600">{row.age} years</span>;
}
if (col === "email") {
return <a href={`mailto:${row.email}`} className="text-blue-600 underline">
{row.email}
</a>;
}
}}
/>
Adds "years" to age and custom styling and makes emails clickable.
4. Handling Row Clicks
Detect when a row is clicked using onRowClick
.
<CustomDynamicTable
tableData={employees}
tableColumns={["name", "age", "email"]}
onRowClick={(row) => alert(`You clicked on ${row.name}`)}
/>
Clicking a row triggers an alert with the employee’s name.
Wrapping It Up 🎯
And just like that, you’ve tamed the table beast.
No more hunting down scattered tables across your codebase. No more tedious manual updates. With a well-structured reusable table component, you’ve unlocked a new level of efficiency—one where tweaks are painless, customization is a breeze, and your future self thanks you for the foresight.
Of course, this is just the beginning. Want to take it even further? Add inline editing, infinite scrolling, or dynamic column resizing. The magic doesn’t stop here. So go forth, wield your newfound table-building powers, and make your UI cleaner, faster, and more maintainable.
Happy coding, wizard! 🪄🚀
You can reach out to me on
Link to the repository:
Top comments (3)
Interesting, I aspire to be as good as this someday..
This is great bro
Interesting 👏