Haseeb.

Mastering Component Composition in React

September 21, 2024

Mastering Component Composition in React

When I first learned about React, I heard about all of its advantages: Virtual DOM is super fast, one-way data flow is very predictable, and JSX is... an interesting way to put markup into JavaScript.

But the biggest advantage of React is one I only got to appreciate over time: The ability to compose components together into more complex components.

The Evolution of Component Thinking

It's easy to miss this advantage if you've always been used to it. Believe it or not, grouping component logic, styles, and markup together into a single component was considered blasphemy about a decade ago.

"What about separation of concerns?"

Well, we still separate concerns, just differently (and arguably better) than before. It's all about code cohesion. The styles of a button, the logic that happens when a button is clicked, and the markup of a button naturally belong together to form that button.

The Pitfall of Conditional Rendering

I don't think we do component composition enough, which is why many applications stop with component composition at a certain point and continue with its natural enemy: conditional rendering.

Consider a simple shopping list component:

export function ShoppingList(props: {
  content: ShoppingList
  assignee?: User
}) {
  return (
    <Card>
      <CardHeading>Welcome 👋</CardHeading>
      <CardContent>
        {props.assignee ? <UserInfo {...props.assignee} /> : null}
        {props.content.map((item) => (
          <ShoppingItem key={item.id} {...item} />
        ))}
      </CardContent>
    </Card>
  )
}

This is perfectly fine. But things get complicated when we start adding multiple states:

export function ShoppingList() {
  const { data, isPending } = useQuery(/* ... */)

  return (
    <Card>
      <CardHeading>Welcome 👋</CardHeading>
      <CardContent>
        {data?.assignee ? <UserInfo {...data.assignee} /> : null}
        {isPending ? <Skeleton /> : null}
        {!data && !isPending ? <EmptyScreen /> : null}
        {data
          ? data.content.map((item) => (
              <ShoppingItem key={item.id} {...item} />
            ))
          : null}
      </CardContent>
    </Card>
  )
}

Early Returns: A Better Approach

The solution? Early returns. This approach reduces cognitive load and makes the code more readable:

function Layout(props: { children: ReactNode; title?: string }) {
  return (
    <Card>
      <CardHeading>Welcome 👋 {props.title}</CardHeading>
      <CardContent>{props.children}</CardContent>
    </Card>
  )
}

export function ShoppingList() {
  const { data, isPending } = useQuery(/* ... */)

  if (isPending) {
    return (
      <Layout>
        <Skeleton />
      </Layout>
    )
  }

  if (!data) {
    return (
      <Layout>
        <EmptyScreen />
      </Layout>
    )
  }

  return (
    <Layout title={data.title}>
      {data.assignee ? <UserInfo {...data.assignee} /> : null}
      {data.content.map((item) => (
        <ShoppingItem key={item.id} {...item} />
      ))}
    </Layout>
  )
}

Key Benefits of This Approach

  1. Reduced Cognitive Load: Each return represents a distinct user state
  2. Easy to Extend: Adding new conditions becomes straightforward
  3. Better Type Inference: TypeScript can more easily understand your code's logic

Learnings

This approach is about both component composition and early returns. The key is to avoid conditional renderings for mutually exclusive states.

Always remember to go back to the drawing board when your components start feeling complex. Break down your UI into logical components, and don't be afraid of a little duplication if it makes your code more readable.

Let's build smarter, more maintainable React applications!