DEV Community

Cover image for Reusable Table Component: Compound Component Pattern + Tailwind CSS
Aᴅᴇ </>
Aᴅᴇ </>

Posted on

Reusable Table Component: Compound Component Pattern + Tailwind CSS

While working on a big project at work, I and my fellow frontend developer faced a problem. There were multiple use cases of tables within our react application and we had just copy-pasted the code for the table all over our codebase

I had built a table and in order to build new screens that had a table, me and my co-worker just copy pasted the code for the table and mapped out the needed table rows. We both felt somehow about such practice as it doesn't adhere to React's reusability principle. What we needed was a table component and the UI framework that we were using on the react application, Chakra UI provided a table component but that still wasn't enough, as the table didn't adhere to the table in the Interface design for the product we were building.

To be as compliant with the design as possible while adhering to React's reusability principle, what we needed was a table component that can be used to visualize various types of data as needed by specific parts of the application. Through research, I was able to build one and reduced the size of the code base by about 10 - 15% ! I saved the day and you can also do the same too by applying this technique that you're about to learn.

The Compound component pattern

The compound component pattern is an advanced developer pattern created by react engineers to improve reusability of UI across the codebase, It may seem slightly complex but it is best understood in practice. So let's get straight to it. To pass data around while using this pattern no props are used! Also in order to ensure that our table is accessible, we are going to be making use of actual table elements as you shall see.

STEP 1: Create a context.

import { createContext } from "react";

const TableContext = createContext();
Enter fullscreen mode Exit fullscreen mode

STEP 2: Create a parent component that consumes the context.

function Table({children}) {
    return (
        <TableContext.Provider value={{}}>
            <div className={`rounded-lg border overflow-scroll`}>
                <table className={`min-w-full`}>{children}</table>
            </div>
        </TableContext.Provider>
    )
}
Enter fullscreen mode Exit fullscreen mode

Since the table component is a parent component and would contain the table header and the table body, the children prop is necessary. The provider's value is empty because we aren't passing any sort of state into the children element as is sometimes necessary in some implementations of this pattern.

STEP 3: Create child components to perform the common task
The common tasks include rendering the the table header and the body. So let's do that, Shall we?
So the first child component for the table is it's header.

function Header({children}) {
    return (
        <thead className="bg-grey-300 text-grey-700 rounded-lg">
            <tr className="text-left">{children}</tr>
        </thead>
    )
}
Enter fullscreen mode Exit fullscreen mode

And the second child component is going to be the body component. This might seem a bit complex but I would explain it to you.

function Body({ data, render }) {
// If there is no data passed in or no data to show
  if (!data?.length)
    return (
      <tbody>
        <tr>
          <td className="text-center py-4 text-slate-400">
            Data not avalaible!
          </td>
        </tr>
      </tbody>
    );

  return <tbody>{data?.map(render)}</tbody>;
}
Enter fullscreen mode Exit fullscreen mode

So what's happening in there is some optional chaining. If there's no data, Data not available simply get's displayed otherwise, the table body is rendered using the "render" prop. This would make more sense once we use this table. It is important to note that the render prop is not an inbuilt react prop like the "children" prop but just a prop that developers who implement this pattern use to tell the function how the data should be rendered.

Let's also just create a heading component to standardize our table further and prevent reusing the same styles over and over again just to make our table uniform. That brings us to a third child component. The heading!

function Heading({ children, className }) {
  return
    <th className={`px-6 py-2 whitespace-nowrap ${className}`}>{children}</th>;
}
Enter fullscreen mode Exit fullscreen mode

We accepted the className prop for more styling options that the user might prefer such as customizing the width of one of the table columns or something.

STEP 4: Add all the child components that we have created as properties to the parent component and export your parent component.

Let's get right into it.

Table.Header = Header;
Table.Heading = Heading;
Table.Body = Body;

export default Table;
Enter fullscreen mode Exit fullscreen mode

With our Table.jsx file looking something of this sort. We are ready to hop on to the most exciting part, using our Table component!

Using our Table Component

Moving over to where we want to use our table component, for me that is my App.js file. We would be using an array of objects to render our table body. Within your desired component's body paste in this sample data.

const storyElements = [
  {
    character: "A curious teenager",
    setting: "A mysterious forest",
    conflict: "Discovering an ancient artifact"
  },
  {
    character: "An exiled noble",
    setting: "A crumbling castle",
    conflict: "Attempting to reclaim their throne"
  },
  {
    character: "A skilled thief",
    setting: "A crowded marketplace",
    conflict: "A heist goes wrong"
  },
  {
    character: "A novice wizard",
    setting: "A magical academy",
    conflict: "Battling a rival student"
  },
  {
    character: "A seasoned detective",
    setting: "A small, sleepy town",
    conflict: "Uncovering a hidden conspiracy"
  },
  {
    character: "A lonely ghost",
    setting: "An old, abandoned mansion",
    conflict: "Seeking redemption or closure"
  }
];
Enter fullscreen mode Exit fullscreen mode

And finally, if you're not rendering any other thing here is what the return statement of your functional component should look like. As you can see below, the child components don't make sense being used as a separate component elsewhere but they are all used together as a unit. This is kind of like the HTML select element. The option children tags don't make sense being used elsewhere apart from within the select element.

  return (
    <div className="px-4 py-6">
      <Table>
        <Table.Header>
          <Table.Heading className="w-[5%]">S/N</Table.Heading>
          <Table.Heading>Character</Table.Heading>
          <Table.Heading>Setting</Table.Heading>
          <Table.Heading>Conflict</Table.Heading>
        </Table.Header>
        <Table.Body
          data={storyElements}
          render={(storyElement, index) => (
            <tr key={index}>
              <td className="px-6 py-4 text-sm whitespace-nowrap">
                {index + 1}
              </td>
              <td className="px-6 py-4 text-sm whitespace-nowrap">
                {storyElement.character}
              </td>
              <td className="px-6 py-4 text-sm whitespace-nowrap">
                {storyElement.setting}
              </td>
              <td className="px-6 py-4 text-sm whitespace-nowrap">
                {storyElement.conflict}
              </td>
            </tr>
          )}
        />
      </Table>
    </div>
  );
Enter fullscreen mode Exit fullscreen mode

And there you have it, your very own responsive, accessible and easy to use Table component. It should look exactly like this at this stage.

For some of you, the table data

elements within the table row element may look a bit disorganized due to the tailwind classes and even feel repetitive to some extent. Feel free to create another child component, call it anything you like, such as "TableBodyData" for instance, provide it as a property to the Table functional component and simply use it in your code.

Using Typescript

For my typescript folks, you're not left out. Here's what your Table component should look like.

// 1. Create a context
const TableContext = createContext(undefined);

interface TableTypes {
  children?: React.ReactNode;
  data?: any[];
  render?: any;
  // For extra styling
  className?: string;
}

// 2. Create parent component
export function Table({ children }: TableTypes) {
  return (
    <TableContext.Provider value={undefined}>
      <div className={`rounded-lg border overflow-scroll`}>
        <table className={`min-w-full`}>{children}</table>
      </div>
    </TableContext.Provider>
  );
}

// 3. Creation of child components to render table header and body.
function Header({ children }: TableTypes) {
  return (
    <thead className="bg-resources-bg text-mid-grey rounded-lg">
      <tr className="text-left">{children}</tr>
    </thead>
  );
}

function Heading({ children, className }: TableTypes) {
  return (
    <th className={`px-6 py-2 whitespace-nowrap ${className}`}>{children}</th>
  );
}

function Body({ data, render }: TableTypes) {
  return <tbody>{data?.map(render)}</tbody>;
}

// 4. Child components added as properties to the parent component.
Table.Header = Header;
Table.Heading = Heading;
Table.Body = Body;

Now allow me explain a few things, the TableContext has a default value of undefined because we are not defining a default value for our context. You can also see that out Body component is much shorter than you remember because the check that we ran earlier is no longer necessary here as TypeScript would ensure that the data prop must be defined before your code is allowed to run wherever this Table component is used. The value type of the value in the TableContext Provider within the parent value is also set to undefined, because no part of the table is consuming any value from the parent component.

The compound component pattern is not restricted to just tables, it can be used for creating counters, pagination, modals and other seemingly complex UI elements that would ordinarily take tons of props to configure. Feel free to explore the possibilities of using this pattern.

Thanks for reading. Also, I'm new to creating content on here so tell me some things you'd like to see or learn about. Have anything you're curious about, ask me in the comment section.

Top comments (0)