Make more things into Components

profile picture

Spencer Miskoviak

May 9, 2020

Photo by Frank Mckenna

One of the great features of React is the encapsulation and reusability of components. It not only allows consistency across a codebase and product, but also allows easily reading and understanding a component tree because of it's declarative nature without needing to understand all of the implementation details.

What about the code and logic that isn't encapsulated in those components? This logic is often scattered and duplicated across the codebase in component's render methods, lifecycle methods, or state. Why isn't more of this code encapsulated in components?

The most commonly overlooked examples are non-visual things like data-fetching, tracking, experimentation, or layout logic that can be duplicated across hundreds of components. However, the needs for these can fit perfectly into the component paradigm and it can offer a handful of advantages.

Let's look at a few simplified examples.

Layout

Common patterns such as buttons and avatars are components that are often added early on in many projects, maybe as part of a design system. However, there are plenty of equally important non-visual (in the sense they don't render anything visual themselves) aspects particularly around layout and spacing that are often ignored.

For example, grids, spacing, or stacking that help position other visual elements like buttons and avatars. One common need is to vertically align two elements, or push two elements to opposite sides of their container. Both of these problems can be easily solved with CSS flex attributes. However, these needs are so common that something like the following code with a few tweaks may be in dozens of components.

<div
  style={{
    display: "flex",
    alignItems: "center",
    justifyContent: "space-between"
  }}
>
  <div style={{ background: "red" }}>Red Content</div>
  <div style={{ background: "blue", height: 100 }}>Blue Content</div>
</div>

Whether you're using CSS-in-JS, vanilla CSS, or CSS Modules this can lead to a lot of one-off elements and styles. Additionally, to understand what's happening it requires mentally parsing all of the CSS attributes or following a class to another file to see what CSS attributes are applied to understand what's happening. What if this pattern of providing a basic flex layout was encapsulated as part of a component?

import * as React from "react";

type Props = Pick<React.CSSProperties, "alignItems" | "justifyContent">;

export const Flex: React.FC<Props> = ({
  children,
  alignItems,
  justifyContent
}) => (
  <div style={{ alignItems, justifyContent, display: "flex" }}>{children}</div>
);

This component achieves the same thing, but now it gives a name to the concept: Flex. Since all possible variations are in one component the styles only need to be defined once which can help reduce the overall size of the CSS (or JavaScript) bundle (assuming the one-off CSS above was duplicated in varying forms across the codebase). Lastly, now when reading the component tree, it's immediately clear all this component is doing is providing some flex layout with a few flex-specific options.

<Flex alignItems="center" justifyContent="space-between">
  <div style={{ background: "red" }}>Red Content</div>
  <div style={{ background: "blue", height: 100 }}>Blue Content</div>
</Flex>

This is a simplified example, but the idea can apply to any layout related concepts: grids, spacing, etc. For example, how often is there a need to apply margin between two component? Is this achieved with one-off CSS, a custom element, or is it encapsulated in components?

Tracking

Another common pattern is the need to implement some type of tracking to understand how people are using an application. Two popular things to track are impressions and clicks to understand what someone is seeing and what they are interacting with.

A simple implementation to handle these needs may look like the following, assuming track is actually sending this information somewhere useful.

export const track = (event: object) => console.log(event);

const TrackExample = () => {
  React.useEffect(() => {
    // It's probably preferable to use something like react-visibility-sensor
    // to only track impressions for things someone could have actually seen.
    track({ name: "track-example", type: "impression" });
  }, []);

  const handleClick = () => {
    track({ name: "track-example", type: "click" });
  };

  return <button onClick={handleClick}>Plz Track Me</button>;
};

When this component mounts an impression will be tracked, and when someone clicks the button that will also be tracked. This approach may require adding this useEffect and handleClick in dozens of components depending on what needs to be tracked.

What if you want to swap out track for another method or change it's API? Now all of these components need to be updated. Or, what if you originally wrote all these components as classes with componentDidMount? If you want to use a hook all these components would need to be refactored. What if you want to track the impression at a more specific component in the tree? That would require a new component to isolate that part of the tree with it's own useEffect hook.

Instead, what if all this logic around track and mounting was only written once as reusable components?

import * as React from "react";
import { track } from "..";

type Props = { name: string };

const Click: React.FC<Props> = ({ name, children }) => {
  const handleClick = () => {
    track({ name, type: "click" });
  };

  return <span onClick={handleClick}>{children}</span>;
};

const Impression: React.FC<Props> = ({ name, children }) => {
  React.useEffect(() => {
    track({ name, type: "impression" });
  }, [name]);

  return <>{children}</>;
};

export const Track = {
  Impression,
  Click
};

Now, all of the above problems are solved. These components can be added to any part of the tree. Your entire feature could be a single huge component and still have fine grain control over impression tracking. Now, it doesn't matter if Track.Impression is using componentDidMount or useEffect. And if it did matter, there's only one place it needs to be updated. Finally, if the track method needs to be swapped out, it also only needs to be done in one place.

<Track.Impression name="track-example">
  <Track.Click name="track-example">
    <button>Plz Track Me</button>
  </Track.Click>
</Track.Impression>

Since the impression tracking is really only a hook, you may consider using a custom useImpressionTracking hook instead. However, this still has the problem of needing to create a new component anytime you need to track an impression at a specific point in the tree. Additionally, click tracking doesn't make as much sense as a hook so I'd prefer to keep consistent APIs between the two (components).

This can also easily extend and apply to experimentation or feature toggling to dynamically show content depending on a toggle's value. It could even reuse these tracking components within since it's likely we want to track impressions anytime someone sees an experiment.

Data-Fetching

A nearly universal product need is some form of data-fetching. This can manifest itself in many ways, but let's say we're fetching some JSON from a REST API. We probably need a way to differentiate statuses while the request is in-flight, if an error occurs, or when it's successful.

const FetchExample = () => {
  const [state, setState] = React.useState<
    | {
        status: "loading" | "error";
      }
    | {
        status: "loaded";
        response: object;
      }
  >({
    status: "loading"
  });

  React.useEffect(() => {
    fetch(`https://jsonplaceholder.typicode.com/posts/4`)
      .then(response => response.json())
      .then(response => {
        setState({ status: "loaded", response });
      })
      .catch(() => {
        setState({ status: "error" });
      });
  }, []);

  switch (state.status) {
    case "error":
      return <div>An error occurred.</div>;
    case "loading":
      return <div>Loading...</div>;
    default:
      return <div>{JSON.stringify(state.response)}</div>;
  }
};

This feature's code is likely similar to dozens of other components. Those components are likely fetching data in a similar way and have the same needs for the different loading statuses. What if we wanted to add a fourth loading status? What if we wanted to swap fetch for something else? All of the different components will need to be updated. Additionally, this too takes a fair amount of reading to understand how all the different pieces work together. As a reader, what we really care about is that we want to fetch some data from a url, and given that data, render something. Or, translated into React...

import * as React from "react";

type State =
  | {
      status: "loading" | "error";
    }
  | { status: "loaded"; response: object };

interface Props {
  url: string;
  children: (state: State) => React.ReactElement<any, any> | null;
}

export const Fetch: React.FC<Props> = ({ url, children }) => {
  const [state, setState] = React.useState<State>({
    status: "loading"
  });

  React.useEffect(() => {
    fetch(url)
      .then(response => response.json())
      .then(response => {
        setState({ status: "loaded", response });
      })
      .catch(() => {
        setState({ status: "error" });
      });

    return () => {
      // also handle cancelling the request if the url changes...
    };
  }, [url]);

  return children(state);
};

And using that, the component usage is an exact translation: we want to Fetch some data from a url, and given that data, render something.

<Fetch url="https://jsonplaceholder.typicode.com/posts/4">
  {data => {
    switch (data.status) {
      case "error":
        return <div>An error occurred.</div>;
      case "loading":
        return <div>Loading...</div>;
      default:
        return <div>{JSON.stringify(data.response)}</div>;
    }
  }}
</Fetch>

You could also consider creating a useFetch hook for something like this instead. It's achieving the same goal as putting more logic into components but each approach has slight tradeoffs. Either way, if the underlying API changes it can now easily be swapped out. Or, it's easy to add error handling for all network requests in one place.

There are plenty of existing packages that already use an API like this such as react-apollo's Query component and useQuery hook.

Conclusion

With these examples in mind, maybe you can think of a few other examples you've experienced while working with React? Keep your eyes open for opportunities where logic could be better represented as a component. It can offer a number of advantages:

  • More declarative, readable component trees by giving a name to concepts.
  • Less duplication of logic which leads to easier maintenance and increased consistency.
  • More flexible since they can be added anywhere in the tree (eg: impression tracking) and composed as needed.

Duplication isn't inherently bad and there are cases where it may be better to have a little duplication than trying to force a single component to handle all of the possible use cases and edge cases. However, there are often common themes throughout a codebase that are overlooked that can be better represented as components.

Tags:

course

Practical Abstract Syntax Trees

Learn the fundamentals of abstract syntax trees, what they are, how they work, and dive into several practical use cases of abstract syntax trees to maintain a JavaScript codebase.

Check out the course