Composing React Components using Hooks

  • Languages
    • Javascript
  • Libraries
    • React

Introduction of Hooks resulted in creation of new patterns for composing components in React. Now, we are able to properly compose logical portions of the application into simple functions and use these functions across many components. In this article, we will be talking about composing UI by conforming to the Single Responsibility Principle using Components and Hooks.

Single Responsibility Principle

What is Single Responsibility Principle (SRP)? SRP states that a module, class, or a function should have responsibility over a single part of functionality. The inventor of this principle defines responsibility as a reason to change. So, if a function needs to be changed because of more than one functionality (e.g View and Logic), it is better to extract these functionalities into their own logical segments.

SRP before Hooks - Container / Component Pattern

SRP in terms of view layer works very well in React due to the fact that React components are just composable functions with input (Props) and output (React Node).

Because React did not provide any solid line between logic and view, a lot of patterns have emerged to conform to SRP. The biggest contender for this was Component / Container pattern that was mainly introduced for Redux. However, this pattern could be used regardless of existence of Redux. Let’s look at an example: assume that we have a grid of items with a delete button. When the button is clicked, an overlay with delete dialog opens on top of the grid item. When delete is confirmed, a server request is executed to delete the item from backend. Intuitively, we would build the following component tree:

- GridContainer: Fetches Data
    - Grid: Displays Grid
        - GridItemContainer: Handles Delete Request Logic
            - GridItemDeleteConfirmContainer: Manages Delete Confirm Overlay logic
                - GridItem: Displays Grid Item
                - DeleteConfirm: Displays Delete Confirm
// I am using function components to show the code
// because it makes the code shorter in comparison to using
// class components. This logic is similar to using
// `useState` and `useEffect` hooks without creating
// custom hooks

const GridContainer = () => {
  // Fetch data
  // data: array of objects coming from fetch
  if (loading) return <Loader />;
  if (error) return <Error error={error} />;
  return <Grid data={data} />;
};

// data: array of objects
const Grid = ({ data }) => {
  return (
    <div className="grid">
      {data.map(item => (
        <GridItemContainer data={item} />
      ))}
    </div>
  );
};

// data: passed grid item object
const GridItemContainer = ({ data }) => {
  // Create necessary delete logic
  // onDelete: Event Handler to delete the object
  // isDeleting: Since it is an async operation,
  // we don't know how much time it will take to perform the operation

  return (
    <GridItemDeleteConfirmContainer
      isDeleting={isDeleting}
      onDelete={onDelete}
      data={data}
    />
  );
};

// isDeleting: if request is in progress
// onDelete: Delete object from backend
// data: passed grid item object
const GridItemDeleteConfirmContainer = ({ isDeleting, onDelete, data }) => {
  // Toggle delete confirm
  // toggleDelete: Event Handler to toggle the overlay
  // untoggleDelete: Event Handler to untoggle overlay
  // deleteConfirm: State that represents whether delete is toggled
  // handleDelete: uses onDelete. After deletion, untoggles the form

  return (
    <>
      <GridItem
        data={data}
        onDeleteClick={toggleDeleteConfirm}
        hideDelete={deleteConfirm}
      />
      {deleteConfirm && (
        <DeleteConfirm
          onDelete={handleDelete}
          isDeleting={isDeleting}
          onCancel={untoggleDelete}
        />
      )}
    </>
  );
};

const GridItem = ({ data }) => {
  return <>{/* Render content here */}</>;
};

By creating three additional “Container” components, we are able to clearly separate logic from each other and from the view. Based on the example, if we need to change delete confirm logic, we will only change GridItemDeleteConfirmContainer; if we need to change the UI, we will only change GridItem etc. The example above does a good job in conforming to SRP.

Try it in CodeSandbox

Note:

In the example above, I am using hooks to build the components; however, I am not taking advantage of custom hooks. This is intentional to structure the components the way we would structure without using hooks.

SRP after Hooks - Custom Hooks for logic

Hooks makes Component / Container pattern almost obsolete. Instead of creating “Container” components to separate logic, we can compose components for view and custom hooks for logic. With this premise, this is how the above components tree will be transformed:

- Grid: Renders grid items
    - useGridFetchAction: Fetches grid data
    - GridItem: Renders the actual grid item. Calls other components and hooks to do proper rendering
        - useGridItemDeleteConfirm: Manages delete confirm state
        - useGridItemDeleteAction: Manages delete network request
        - GridItemContent: Renders grid data
        - DeleteConfirm: DeleteConfirm overlay

Let’s see the code:

const useGridFetchAction = () => {
  // fetch all grid data
  return [data, error, loading];
};

const Grid = () => {
  const [data, error, loading] = useGridFetchAction();

  if (loading) return <Loading />;
  if (error) return <Error error={error} />;

  return (
    <div className="grid">
      {data.map(item => (
        <GridItem data={item} />
      ))}
    </div>
  );
};

const useGridItemDeleteConfirm = onDelete => {
  // state and handlers

  return [
    deleteConfirm,
    handleDelete,
    toggleDeleteConfirm,
    untoggleDeleteConfirm
  ];
};

const useGridItemDeleteAction = data => {
  // delete handler

  return [handleDelete, isDeleting];
};

const GridItem = ({ data }) => {
  const [onDelete, isDeleting] = useGridItemDeleteAction(data);

  const [
    deleteConfirm,
    handleDelete,
    toggleDeleteConfirm,
    untoggleDeleteConfirm
  ] = useGridItemDeleteConfirm(onDelete);

  return (
    <>
      <GridItemContent
        data={data}
        onDeleteClick={toggleDeleteConfirm}
        hideDelete={deleteConfirm}
      />
      {deleteConfirm && (
        <DeleteConfirm
          onCancel={untoggleDeleteConfirm}
          onDelete={handleDelete}
          isDeleting={isDeleting}
        />
      )}
    </>
  );
};

const GridItemContent = ({ data }) => {
  return <>{/* Render content here */}</>;
};

Try it in CodeSandbox

Because the component tree is flattened and hooks are “inside” components, SRP looks broken at first glance. It is not. Let’s render delete confirm component:

<DeleteConfirm onCancel={untoggleDeleteConfirm} onDelete={handleDelete} />

Even though this has an XML-like syntax, if we transpile this function to Javascript, the result will be a simple function with bunch of arguments, just like how we call hooks:

React.createElement(
  DeleteConfirm,
  { onCancel: untoggleDeleteConfirm, onDelete: handleDelete },
  null
);

When you think of all components and hooks being just functions, it makes it much easier to think of composition and separation of responsibilities. They are just functions that use other functions to build parts of the UI. These functions can be related to UI (React component) or logic (custom hooks). In terms of structure, all we did was flatten the tree: instead of having each functionality at a deeper level in the tree, we shifted multiple functionalities to the same level. This makes thinking about components leaner while also retaining SRP.

Are Containers Obsolete Now?

When transitioning into hooks, this is a question that I have personally asked and have seen many others ask. After some thinking, I think this logic is not obsolete but its meaning has changed. Hooks change the way we compose our components in a positive way. However, with all its advantages, there is one caveat when thinking about composition (this is not a hooks issue, more of a thinking issue). It is very easy to enter the sinkhole of creating “generic” components when using hooks. To explain my point better, let’s look at an example. Let’s say that we have a dashboard page for all the users in the system. In order to create or update a user, we have a UserForm component. This component renders and manages form inputs:

const UserForm = ({ onSubmit, initialValues = {} }) => {
	const [handleSubmit, setValue, getValue] = useForm(onSubmit, initialValues);

	return (
		<form onSubmit={handleSubmit}>
			<label htmlFor="name">Name:</label>
			<input type="text" name="name" onChange={e => setValue(‘name’, e.target.value)} value={getValue(‘name’)} />
			{/* other form fields */}
			<button type="submit">Save</button>
		</form>
	);
};

We can pass any action to this form during rendering:

const UsersPage = () => {
  const createUser = useCreateUserAction();
  const updateUser = useUpdateUserAction();
  const [formData, setFormData] = useState({});

  return (
    <>
      <UserForm
        onSubmit={!formData.id ? createUser : updateUser}
        initialValues={formData}
      />
      <button onClick={() => setFormData({})}>Create</button>
      {/* some other logic to show edit form */}
    </>
  );
};

Now, let’s assume that we have another Groups page with a relationship to the User model and we want to have a functionality to update or create a user in-place, without leaving Groups page. Since we already have UserForm component, we can reuse the component in Groups. However, because the form component can accept any action, we need to import create and update action hooks and pass them to components. This is my definition of a generic component. Flexibility is all about tradeoff — does it offer enough value? Considering the fact that, this component is only used for two operations, we will need to repeat importing hooks and passing them as props to the components.

I have looked online a bit to find an appropriate name for this “principle” but couldn’t find anything. For the sake of simplicity, I am going to call it “containment.” Containment plays a big role in reusability. When a specialized component is reused component, it is beneficial to render it with minimal friction. If a component that is only used for two operations is generic, we are not getting enough value from the flexibility it provides.

This is where we go back to good old Container / Component pattern… but with a twist: we are not using this pattern to separate logic from view but rather using it to specialize a component for reusability. Here is how our specialized components look like:

const CreateUserForm = () => {
  const createUser = useCreateUserAction();

  return <UserForm onSubmit={createUser} />;
};

const UpdateUserForm = ({ initialValues }) => {
  const updateUser = useUpdateUserAction();

  return <UserForm onSubmit={updateUser} initialValues={initialValues} />;
};

Now, we can use these “Containers” to render the forms.

Final Words

Hooks have been out for a while and they have become a huge part of React. Just like a lot of decision making in React apps, every individual / organization has their own set of rules for building UI. The ideas in this post are rules that I use when thinking about building UI. Hope you find them useful!