DEV Community

loading...

New React Component Pattern? Compound Components w/ a Hook

droopytersen profile image Andrew Petersen ใƒป6 min read

TL;DR

  • React Components == UI and React Hooks == Behavior
  • Often, UI is coupled to behavior. That's okay.
    • isOpen, and closeModal (Behavior), feel pretty coupled to a Modal component (UI).
  • Sometimes the parent component needs access to that "behavior data".
    • So should the parent own the "behavior data" even though it is coupled to the child component?
    • Ex: The parent creating a Modal needs to know if a modal has closed so the parent can cancel an async request. So does the parent have to own the isOpen state and recreate the modal boilerplate every usage?
  • The big thesis: Expanding the Compound Components pattern to also return hooks could be an elegant solution.

Here is the final solution if want to jump straight into the code*.

https://codesandbox.io/s/compount-components-with-a-hook-txolo

*I am using a Material UI table here because this stemmed from a work project. However, the concepts should apply with or without a component library.

In this article I am building a Table component, but in a previous article, I ran into similar problems building a Modal. There, I experimented with returning a Component definition from a custom React hook. Here, I flip it and return the hook on the Component.

Coupled UI & Behavior

The fundamental problem is that you have UI and behavior that are tightly coupled. You need the "behavior data" inside the component to render, but you also need access to the "behavior data" outside/above the component.

For example you want a custom Table component that can:

  • Be used very simply just to encapsulate some brand styling.
  • Optionally, be configured to sort items, and display the column headers in a way that indicates which column is being sorted.

Table Component

If the Table itself were to own the sorting behavior, the Table would need to be explicitly given the full set of items. But wait, how would you control what the table looks like then?

If the Table component were to own the sorting behavior, you'd have to pass it all your items

<Table items={myData} enableSort >
  {/* What do you map over to display table rows? */}
  {/* It's not 'myData' because that isn't sorted. */}
</Table>
Enter fullscreen mode Exit fullscreen mode

You could try something like a renderRow prop, or use the "render as children" pattern.

Neither option feels right

// OPTION A: renderRow prop - This will to turn into prop sprawl 
// as we identify more render scenarios (or be very un-flexible)
<Table
  items={myData}
  enableSort
  renderRow={(item) => <tr><td>{item.name}</td/>...</tr>}
/>

// OPTION B: Render as children - this syntax just feels gross
<Table items={myData} enableSort>
  {({ sortedItems} ) => (
    {sortedItems.map((item) => (
      <tr>
        <td>{item.name}</td/>
        ...
      </tr>
    )}
  )}
</Table>
Enter fullscreen mode Exit fullscreen mode

Besides the fact that it already smells, we'd still have to figure out how to render the Table Header.

  • How would the Table know which columns to use?
  • We could expose a renderHeader prop and let developers show whatever they want. But then we'd be forcing developers to handle the sorting UI (showing the correct Sort Icon) on their own too.
  • That feels like it defeats the purpose of the Table component!

We've already hit a wall and we've only discussed sorting. What if we also want to support paging? What about a textbox to filter table rows?

  • We don't want to force developers to implement those behaviors themselves.
  • But we also can't bake it into the component because we need to give them control over what it looks like.
  • Lastly, we want to provide "happy path" UI defaults to make the component really simple to use.

Compound Components w/ Hooks

My idea is to take the Compound Components Pattern and combine it with custom React Hook composition.

Take a look at this usage example, then scroll below to see a breakdown of the notable elements.

import React from "react";
import Table from "./table/table";
import users from "./data";


export default function SortingDemo() {
  // This is the interesting bit, the Component definition has
  // a custom hook attached to it.
  const { showingItems, sorting } = Table.useTable(users, {
    sortKey: "firstName",
    sortDir: "desc"
  });

  // The parent has access to behavior data
  console.log("You are sorting by: ", sorting.sortKey);

  return (
    <Table>
      {/* 
          Here, we take advantage the fact that the hook
          returns the behavior data, 'sorting', in the same
          shape needed for the Table.Header props.
      */}
      <Table.Header {...sorting}>
        <Table.Column id="firstName">First Name</Table.Column>
        <Table.Column id="lastName">Last Name</Table.Column>
        <Table.Column id="department">Department</Table.Column>
        <Table.Column id="jobTitle">Title</Table.Column>
      </Table.Header>

      <Table.Body>
        {/* Show the first 10 sorted items */}
        {showingItems.slice(0, 10).map((item) => (
          <Table.Row key={item.id}>
            <Table.Cell>{item.firstName}</Table.Cell>
            <Table.Cell>{item.lastName}</Table.Cell>
            <Table.Cell>{item.department}</Table.Cell>
            <Table.Cell>{item.jobTitle}</Table.Cell>
          </Table.Row>
        ))}
      </Table.Body>
    </Table>
  );
}
Enter fullscreen mode Exit fullscreen mode

Things to note:

  1. In addition to compound components like Table.Column and Table.Cell, the Table component also has a useTable hook attached to it.
  2. The useTable hook returns a sorting object that:
    • Provides the parent component access to the sorting behavior like the current sortKey.
    • The sorting object is structured to overlap the prop signature of the Table.Header component so that it's really easy to use the built-in sorting UI if desired.
    • <Table.Header {...sorting}> is all it takes to opt into the sorting UI.

The beauty of this pattern is it doesn't complicate the simple scenarios. We can use the Table for UI things without having to worry about any of the hook/behavior code.

A simple table w/ zero behavior

import React from "react";
import Table from "./table/table";
import users from "./data";

export default function SimpleDemo() {
  return (
    <Table>
      <Table.Header>
        <Table.Column>First Name</Table.Column>
        <Table.Column>Last Name</Table.Column>
        <Table.Column>Department</Table.Column>
        <Table.Column>Title</Table.Column>
      </Table.Header>

      <Table.Body>
        {users.slice(0, 5).map((item) => (
          <Table.Row key={item.id}>
            <Table.Cell width="120px">{item.firstName}</Table.Cell>
            <Table.Cell width="130px">{item.lastName}</Table.Cell>
            <Table.Cell width="170px">{item.department}</Table.Cell>
            <Table.Cell width="250px">{item.jobTitle}</Table.Cell>
          </Table.Row>
        ))}
      </Table.Body>
    </Table>
  );
}
Enter fullscreen mode Exit fullscreen mode

This pattern can also scale to add more and more behavior without over complicating the usage.

We could add more behavior to our useTable hook

const { showingItems, sorting, paging, filtering, stats } = Table.useTable(
  users,
  {
    sortKey: "firstName",
    sortDir: "desc",
    filterKeys: ["firstName", "lastName", "department", "jobTitle"],
    pageSize: 10
  }
);
Enter fullscreen mode Exit fullscreen mode

Because the behavior data comes from a hook we have it readily available to do whatever our application needs from a logic perspective, but we can also easily (and optionally) render it using the coupling between the built-in Table compound components and the useTable hook.


// Render the built-in paging controls
<Table.Paging {...paging} onChange={paging.goTo} />

// Render the built-in search box
<Table.Search
  value={filtering.filterText}
  onChange={filtering.setFilterText}
/>

// Render custom "stats" 
<div>
  Showing {stats.start} - {stats.end} of {stats.totalItems}
</div>
Enter fullscreen mode Exit fullscreen mode

Isn't tight coupling bad?

You may have read "The sorting object is structured to overlap the prop signature of the Table.Header" and involuntarily shuddered at the tight coupling.

However, because hooks are so easy to compose, we can build the "core behaviors" totally decoupled, then compose them (in the useTable hook) in a way that couples them to the (Table) UI.

If you look at the implementation of useTable, you'll see it is mostly the composition of individual, decoupled behavior hooks, useFilteredItems, usePaging, and useSorting.

useTable.js is really just responsible for pulling in decoupled behavior hooks, and tweaking things to line up perfectly with the Table components.

import { useFilteredItemsByText } from "../hooks/useFilteredItems";
import { usePagedItems } from "../hooks/usePaging";
import { useSortedItems } from "../hooks/useSorting";

export function useTable(
  allItems,
  { filterKeys = [], sortKey, sortDir, pageSize }
) {
  pageSize = pageSize || allItems.length;
  const { filteredItems, ...filtering } = useFilteredItemsByText(
    allItems,
    filterKeys
  );
  const { sortedItems, ...sorting } = useSortedItems(filteredItems, {
    sortKey,
    sortDir
  });

  const [showingItems, paging] = usePagedItems(sortedItems, pageSize);

  const stats = {
    totalItems: allItems.length,
    start: (paging.currentPage - 1) * pageSize + 1,
    end: Math.min(paging.currentPage * pageSize, allItems.length)
  };

  return {
    showingItems,
    filtering,
    sorting,
    paging,
    stats
  };
}
Enter fullscreen mode Exit fullscreen mode

In the end there is nothing really earth shattering here. We've already been building hooks like this, and we've already been building components like this. I'm just suggesting (for certain situations) to embrace the coupling and package them up together.

Thanks for making it this far. Let me know what you think in the comments. I haven't really seen anyone doing something like this yet so I am nervous I'm missing a tradeoff.

Here is the final codesandbox

Discussion (1)

pic
Editor guide
Collapse
cliffordfajardo profile image
Clifford Fajardo

This is awesome Andrew!. I'm going to dig into more of this code. We use MUI at work, so its a nice bonus that you used MUI for the examples๐Ÿ˜„