TL;DR
- React Components == UI and React Hooks == Behavior
- Often, UI is coupled to behavior. That's okay.
-
isOpen
, andcloseModal
(Behavior), feel pretty coupled to aModal
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 theisOpen
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 aModal
. 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.
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>
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>
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>
);
}
Things to note:
- In addition to compound components like
Table.Column
andTable.Cell
, theTable
component also has auseTable
hook attached to it. - The
useTable
hook returns asorting
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 theTable.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.
- Provides the parent component access to the sorting behavior like the current
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>
);
}
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
}
);
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>
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
};
}
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
Top comments (3)
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😄
This is great!
I joined the site just to tell you that.
I was having issues with my Components via hooks, re-rendering on every change. This cleared it up. I was close!
Thanks again!
Great write up! Do you have social media or any other places where you share your coding style and philosophies?