Imagine this. You're designing a React component, and it's going great. You've been able to elegantly handle all the use cases you need in a declarative way. But then... You think of a new scenario that doesn't fit into your design, and a wrench gets thrown into your beautiful API. It needs to do something imperative like manually reload a grid or reset a form. You've got the perfect API for 90% of the use cases, but this one tiny requirement has ruined it all. What do you do?
Believe me, I've been there. It's driven me crazy for a while, but I finally came up with a pattern that solves it pretty well. Let me show you.
Let's Build a Grid
Let's say we're trying to make a paged grid component that fetches its own data. This is going to be used everywhere in the company as the go-to grid component, so we want to make it as simple as possible for a developer to implement.
We set it up with a source
prop for fetching the data, and call it in a useEffect
when the page number changes.
function Grid({ source }) {
const [data, setData] = useState({ values: [], count: 0 });
const [page, setPage] = useState(1);
// fetch data on page change
useEffect(() => {
getData();
}, [page]);
function getData() {
// call the `source` prop to load the data
return source(page).then((results) => {
setData(results);
});
}
return (
// ...
);
}
It would be used like this:
function PersonGrid() {
return (
<Grid
source={page =>
fetch(`/api/people?page=${page}`)
.then(res => res.json())
}
// ...
/>
);
}
This works great for really simple use cases. The developer just has to import Grid
, pass in source
, and it just works.
Here Comes the Wrench
Later on, functionality is added to the PersonGrid
screen that allows the user to add new people, and a problem arises. The Grid
controls the fetch, and since it doesn't know that a new person is added, it doesn't know to reload. What we need is an external way of handling the data. Let's refactor what we have to do that.
We'll move the state and fetching logic into its own hook called useGrid
, which makes the Grid
component really simple. Its only job now is to render data from the instance
prop.
function useGrid({ source }) {
const [data, setData] = useState({ values: [], count: 0 });
const [page, setPage] = useState(1);
useEffect(() => {
getData();
}, [page]);
function getData() {
return source(page).then((results) => {
setData(results);
});
}
return {
data,
page
};
}
function Grid({ instance }) {
return (
// ...
);
}
In our PersonGrid
component, we create our grid instance with the hook and pass it to the Grid
.
function PersonGrid() {
const grid = useGrid({
source: page =>
fetch(`/api/people?page=${page}`)
.then(res => res.json())
});
return (
<Grid
instance={grid}
// ...
/>
);
}
With our data being handled in its own hook, that makes the reload scenario straight forward.
function useGrid({ source }) {
const [data, setData] = useState({ values: [], count: 0 });
const [page, setPage] = useState(1);
useEffect(() => {
getData();
}, [page]);
function getData() {
return source(page).then((results) => {
setData(results);
});
}
return {
data,
page,
reload: getData
};
}
Now after we add a person in PersonGrid
, we just need to call grid.reload()
.
Analyzing the APIs
Let's take a step back and analyze these two approaches based on the scenarios.
The first iteration where the Grid
was handling its fetching internally was really easy to use. It only ran into issues when we got into the data reloading scenario.
The second iteration using the useGrid
hook made the data reloading scenario simple, yet made basic use cases more complex. The developer would have to know to import both useGrid
and Grid
. This increase in surface area of the component API needs to be taken into consideration, especially for the simple use cases.
We want to have the component-only API for simple use cases, and the hook API for more complex ones.
Two APIs, One Component
If we go back to the Grid
component, we can include both the source
and instance
props.
function Grid({
source,
instance = useGrid({ source })
}) {
// Any optional props that need to be used in here should come through the `useGrid` hook.
// `instance` will always exist, but the optional props may not.
return (
// ...
);
}
Notice that we're getting source
in as a prop, and we're using it to create a useGrid
instance for the instance
prop.
With this pattern, we can have both component APIs. Going back to the two different usages, they will both work now using the same Grid
component.
In this case, we use the instance
prop (the source
prop isn't needed, since it's in the hook).
function PersonGrid() {
const grid = useGrid({
source: page =>
fetch(`/api/people?page=${page}`)
.then(res => res.json())
});
return (
<Grid
instance={grid}
// ...
/>
);
}
And in this case, we use the source
prop, which builds an instance under the hood.
function PersonGrid() {
return (
<Grid
source={page =>
fetch(`/api/people?page=${page}`)
.then(res => res.json())
}
// ...
/>
);
}
The Rules of Hooks
Now before you bring out your pitchforks and say "you can't optionally call hooks!", hear me out. Think of why that is a rule in the first place. Hooks must be always called in the same order so the state doesn't get out of sync. So what that means is that a hook must always be called or it can never be called.
In our new API, there will never be a case when a developer conditionally provides the instance
prop. They will either provide the instance
prop, which means the defaulted useGrid
won't be used, or they'll use the source
prop, meaning the useGrid
hook will always be called. This satisfies the rules of hooks, but you'll have to tell ESLint to look the other way.
Summary
- Mixing declarative and imperative APIs can be difficult to produce the most simple API in all use cases
- Using a hook to control the component's logic and making it a default prop value allows both imperative and declarative APIs to coexist
Top comments (1)
Hi, and Thanks for writing this.
May I present you an alternative? I would solve this a little bit different.
I would maintain the
<Grid />
as it is, create a<RawGrid />
And then
useGrid
can be pair withRawGrid
.Compare between this and the one in the article: The difference is simply a single component that can have many way to consume and two components but each of them have specific API.
While I prefer having a lot of components with specific API, I can see pros and cons in both ways. Sometimes single component can be easier to use because everything is there, and sometimes harder to use because it has so many different behavior based on optional parameters.
So I will just leave an alternative solution and call it a day.