DEV Community

Cover image for Building a Reusable Table with React, Typescript, Tailwindcss, and Shadcn/UI
Paul Emas
Paul Emas

Posted on

Building a Reusable Table with React, Typescript, Tailwindcss, and Shadcn/UI

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
Enter fullscreen mode Exit fullscreen mode

Image description

Then select Typescript:

Image description

Once the project is created, navigate into the project directory and install dependencies:

cd project-name
npm install
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()],
});
Enter fullscreen mode Exit fullscreen mode

Import Tailwind CSS to your index.css

@import "tailwindcss";
Enter fullscreen mode Exit fullscreen mode

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/*"]
    }
}
Enter fullscreen mode Exit fullscreen mode

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/*"
      ]
    }
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Add the following code to the vite.config.ts so your app can resolve paths without error

npm install -D @types/node
Enter fullscreen mode Exit fullscreen mode
// 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"),
    },
  },
});

Enter fullscreen mode Exit fullscreen mode

Run the shadcn-ui init command to setup your project:

npx shadcn@latest init
Enter fullscreen mode Exit fullscreen mode

During setup, select "Slate" as the theme (or choose any other preferred theme).

Image description

To verify the installation, add a button component:

npx shadcn@latest add button
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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;
}[];
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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

Image description

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";
Enter fullscreen mode Exit fullscreen mode

Let's initialize our table with Typescript Generics

const CustomDynamicTable = <T extends object>(
  props: CustomDynamicTableProps<T>
) => {
Enter fullscreen mode Exit fullscreen mode
  • <T extends object> ensures that the component works with any structured data.
  • TypeScript enforces that tableData is an array of objects where tableColumns contain valid keys.

Adding helper functions

function shouldRender(key: Extract<keyof T, string>) {
  if (excludeColumns.includes(key)) return false;
  return true;
}
Enter fullscreen mode Exit fullscreen mode

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(" ");
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Add the whatToRenderHeader function

  const whatToRenderHeader = (col: StringKeys<T>) => {
    if (!shouldRender(col)) return null;
    const renderedContent = customHeadRender ? customHeadRender(col) : null;
    return renderedContent || splitStringByUnderscore(col);
  };
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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]
);
Enter fullscreen mode Exit fullscreen mode

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

Image description

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;

Enter fullscreen mode Exit fullscreen mode

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
/>
Enter fullscreen mode Exit fullscreen mode

Hides the "email" and "phone" columns while displaying the rest

Image description

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>
        );
      }
    }}
  />
Enter fullscreen mode Exit fullscreen mode

Targets the email header and changes it from "Email" to "EMAIL ADDRESS" and adds a blue text color.

Image description

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>;
    }
  }}
/>
Enter fullscreen mode Exit fullscreen mode

Adds "years" to age and custom styling and makes emails clickable.

Image description

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}`)}
/>
Enter fullscreen mode Exit fullscreen mode

Clicking a row triggers an alert with the employee’s name.

Image description


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

LinkedIn

Link to the repository:

https://github.com/Paul-emas/reuseable-table

Top comments (3)

Collapse
 
ezehdonjay77 profile image
Ezehdonjay77

Interesting, I aspire to be as good as this someday..

Collapse
 
mrbelove profile image
Belove Okorochukwu

This is great bro

Collapse
 
alaa-samy profile image
Alaa Samy

Interesting 👏