Photo by Florian Olivo on Unsplash
Derek Davis

Don't Sacrifice Your Declarative API for One Use Case - A React Pattern for Conditional Hooks

Designing React components that support declarative and imperative APIs

aerial view on weaving concrete roads

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