Effective abstraction in a modern React project

Effective abstraction in a modern React project

This post is for Day 11 of Mercari Advent Calendar 2021, brought to you by Gary Forster from the Mercari Web Personalization team.

Here at Mercari we use React in a lot of our projects. Its simplicity and unopinionated nature as a UI library, as opposed to a fully fledged framework is certainly one of its appeals. However with larger projects, this lack of constraint can become problematic without adopting good practices to ensure our code is well structured and maintainable.

A clear separation of concerns between business logic and UI is one key example of this. Previously, before the introduction of React Hooks and Context Providers, it was common to use tools such as Redux to handle state management. A standard practice was to maintain a clear division between components housing state and logic, often referred to as Containers, and those specifically tasked with rendering content to the page, Presentational Components.

But how can we achieve a similar level of clarity adopting newer React features?

The answer, custom Hooks!

For those unfamiliar, a custom Hook is essentially a plain JavaScript function (or superset thereof) whose name starts with use and that possesses the ability to call other Hooks. A common example is that of a toggle:

const useToggle = () => {
  const [toggled, setToggled] = React.useState(false)
  // Simplified for brevity (if we are passing this function to any child components we may want to memoize this function)
  const toggle = () => {
    setToggled(toggled => !toggled)
  }
  return [toggled, toggle]
}

We could handle this logic directly inside the component, however by extracting it into its own function we get a number of benefits.

The most well-known of these as stated in the official documentation is to prevent duplication of common logic throughout our code. This in itself is highly valuable, however the benefits are quite apparent. Another powerful use case that is often overlooked however is that of abstraction.

At Mercari, facilitating smooth communication between both parties of a transaction is a key part of our platform. One way we achieve this is by allowing potential buyers to comment on a seller’s listing. Say for example we wanted to create a component to show a list of comments for an item. We might initially have something like this:

const ItemComments = ({ itemId }) => {
  // Asynchronous request to fetch the currently logged in user
  const { user } = useUser();
  // Asynchronous request to request the relevant item and comments
  const { item } = useItem(itemId);

  const deleteComment = (commentId) => {
    CommentAPI.deleteComment(commentId);
  };

  if (!item || !user) {
    return 'Loading...';
  }

  return (
    <div>
      <h2> Comments </h2>
      // Iterate over each comment
      {item.comments.map((comment) => (
        <div>
          <img src={comment.userAvatar} />
          <p>{comment.message}</p>
          {item.sellerId === user.id && (
            <button onClick={() => deleteComment(comment.id)}>Delete</button>
          )}
        </div>
      ))}
    </div>
  );
};

What we want to achieve in this case is simple:

  • Load comments and render to the page
  • Allow comments to be deleted provided the user is the seller

Here you may notice that we are already using a couple of custom Hooks to retrieve our data. For the sake of conciseness I won’t go into the details of these but we can assume that some asynchronous request is made and the response is returned.

Looking at the code we can see the logic and UI are coupled. To know if the UI is ready we need to know that both our data sources have loaded and we can also see that the ability to delete a comment depends on the current user.

With custom Hooks, by moving our logic out of the component we set up an explicit API through which all changes to our component must pass. This in turn allows us to test in isolation and substantially increase readability.

Let’s take a look at what our dreamed up component might look like with a new custom Hook:

const ItemComment = ({ itemId }) => {
  // Our new custom hook
  const { loading, comments, deleteComment } = useItemComments(itemId);

  if (loading) {
    return 'Loading...';
  }

  return (
    <div>
      <h2> Comments </h2>
      {comments.map((comment) => (
        <div>
          <img src={comment.userAvatar} />
          <p>{comment.message}</p>
          {deleteComment  && <button onClick={() => deleteComment(comment.id)}>Delete</button>}
        </div>
      ))}
    </div>
  );
};

Much simpler! For those of you who are curious, the associated custom Hook would be

const useItemComments = (itemId: string) => {
  const { user } = useUser();
  const { item } = useItem(itemId);

  const deleteComment = (commentId: string) => {
    CommentAPI.deleteComment(commentId);
  };

  return {
    loading: !user || !item,
    comments: item?.comments || [],
    deleteComment: item.sellerId === user.id && deleteComment,
  };
};

Now alongside increasing readability we can neatly split our tests into two categories, those asserting our expectations for the UI, and those validating any logic. Here are a couple of examples:

Note: these test are written using React Testing Library

// Test UI
it('should display a delete button', () => {
  mockItemComments({
    loading: false,
    comments: [...],
    deleteComment: () => {},
  });
  const { getByText } = render(<ItemComments itemId="mock-item-id" />);
  expect(getByText('Delete')).toBeVisible();
});

// Test Logic
it('should not allow normal users to delete a comment', () => {
  mockUser({
    id: 'mock-user-id'
  })
  mockItem({
    sellerId: 'mock-seller-id'
  })
  const {
    result: { current },
  } = renderHook(() => useItemComments('mock-item-id'));
  expect(current.canDelete).toEqual(false)
});

The mock... functions here can be easily implemented using Jest’s spyOn method.

Although this example is relatively simple, by adopting this strategy for more complex functionality and components we can maintain a cleaner separation of concerns and thoroughly unit test our projects.

One final note: although this pattern can be useful, as always it’s best to avoid premature abstraction. That is, in simple cases extracting your logic into a custom Hook can conversely reduce readability and over complicate our solution. I therefore recommend as with all things in life trying to find a happy balance.

I hope this article could give you some insight and if you’re interested in working at Mercari please be sure to check out our openings. We’re hiring!

Tomorrow’s article, the twelfth in the series will be by Thi Doan. Be sure to check back again tomorrow to see what’s behind door number 12!