Component Composition is great btw
— JavaScript, TypeScript, React — 5 min read
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 components.
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 yes, we still separate concerns, just differently (and arguably better) than before. This graphic, which I first saw in Max's tweet, summarizes it very well:
Separation of concerns still exists, the question is just: where do you separate? Should the boundary really be the programming language we write in?
(attached graphic courtesy of @areaweb)
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. It's a much better grouping than "here are all your styles of all your application in a single layer".
It took us some time to really appreciate this "thinking in components", and I think it's still hard to sometimes find out where those boundaries are. The "new" react docs have a great section about Thinking in React, where they highlight that the first step should always be to break the UI into a component hierarchy.
I don't think we do this enough, which is why many applications stop with component composition at a certain point and continue with it's natural enemy: conditional rendering.
Conditional rendering
Inside JSX, we can conditionally render other components. This is nothing new, and it's also not terrible or evil by itself. Consider the following component that renders a shopping list and optionally adds some user information about the person that's assigned to the list:
1export function ShoppingList(props: {2 content: ShoppingList3 assignee?: User4}) {5 return (6 <Card>7 <CardHeading>Welcome 👋</CardHeading>8 <CardContent>9 {props.assignee ? <UserInfo {...props.assignee} /> : null}10 {props.content.map((item) => (11 <ShoppingItem key={item.id} {...item} />12 ))}13 </CardContent>14 </Card>15 )16}
I would say this is perfectly fine. If the shopping list isn't assigned to anyone, we'll just leave out that part of our rendering. So where's the problem?
Rendering multiple states conditionally
I think conditional rendering inside JSX starts to become a problem when we use it for rendering different states of a component. Suppose we refactor this component to become self contained by reading the shopping list data directly from a query:
1export function ShoppingList() {2 const { data, isPending } = useQuery(/* ... */)3
4 return (5 <Card>6 <CardHeading>Welcome 👋</CardHeading>7 <CardContent>8 {data?.assignee ? <UserInfo {...data.assignee} /> : null}9 {isPending ? <Skeleton /> : null}10 {data11 ? data.content.map((item) => (12 <ShoppingItem key={item.id} {...item} />13 ))14 : null}15 </CardContent>16 </Card>17 )18}
Self contained components are great because you can freely move them around in your application, and they will just read their own requirements, like in this case, a query. This inlined condition seems okay (it's not), as we basically want to render a Skeleton
instead of data
.
Evolving the component
One problem here is that this component just doesn't evolve very well. Yes, we can't see in the future, but making the most common thing (adding more functionality) simple to do is a very good idea.
So let's add another state - if no data
comes back from the API call, we'd want to render a special <EmptyScreen />
. Shouldn't be hard to change the existing condition:
1export function ShoppingList() {2 const { data, isPending } = useQuery(/* ... */)3
4 return (5 <Card>6 <CardHeading>Welcome 👋</CardHeading>7 <CardContent>8 {data?.assignee ? <UserInfo {...data.assignee} /> : null}9 {isPending ? <Skeleton /> : null}10 {data ? (11 data.content.map((item) => (12 <ShoppingItem key={item.id} {...item} />13 ))14 ) : (15 <EmptyScreen />16 )}17 </CardContent>18 </Card>19 )20}
Of course you'll quickly spot the bug 🐞 we've just introduced: This will show the <EmptyScreen />
when we are in pending
state, too, because in that state, we also have no data. Easily fixable by adding another condition instead:
1export function ShoppingList() {2 const { data, isPending } = useQuery(/* ... */)3
4 return (5 <Card>6 <CardHeading>Welcome 👋</CardHeading>7 <CardContent>8 {data?.assignee ? <UserInfo {...data.assignee} /> : null}9 {isPending ? <Skeleton /> : null}10 {!data && !isPending ? <EmptyScreen /> : null}11 {data12 ? data.content.map((item) => (13 <ShoppingItem key={item.id} {...item} />14 ))15 : null}16 </CardContent>17 </Card>18 )19}
But is this still "one component"? Is this easy to read? There are so many question marks and exclamation marks in this markup it makes my brain hurt a bit. Cognitive Load is what matters. I can't easily see what the user will see on their screen if they are in pending
state, or if they are in empty
state, because I'd have to parse all these conditions first.
I'm not even talking about adding another state here, because it should be clear that we would have to (mentally) go through each step and check if we would want to render this part in that new state as well.
Back to the drawing board
At this point, I would suggest to listen to the React docs and break down what the user actually sees on the screen into boxes. It might give us a clue about what is related enough to become it's own component:
In all three states, we want to render a shared "layout" - the red part. That's why we made our component in the first place - because we have some common parts to render. The blue stuff is what's different between the three states. So how would a refactoring look like if we'd extract the red parts to their own layout component that accepts dynamic children
:
1function Layout(props: { children: ReactNode }) {2 return (3 <Card>4 <CardHeading>Welcome 👋</CardHeading>5 <CardContent>{props.children}</CardContent>6 </Card>7 )8}9
10export function ShoppingList() {11 const { data, isPending } = useQuery(/* ... */)12
13 return (14 <Layout>15 {data?.assignee ? <UserInfo {...data.assignee} /> : null}16 {isPending ? <Skeleton /> : null}17 {!data && !isPending ? <EmptyScreen /> : null}18 {data19 ? data.content.map((item) => (20 <ShoppingItem key={item.id} {...item} />21 ))22 : null}23 </Layout>24 )25}
That's ... confusing. 🫤 We seemingly haven't achieved anything - this isn't really better. We still have the same conditional mess as before. So where am I going with this?
Early returns to the rescue
Let's also think about why we added all these conditions in the first place 🤔. It's because we are inside JSX, and inside JSX, we can only write expressions, not statements.
But now, we don't have to be inside JSX anymore. The only JSX we have is a single call to <Layout>
. We could just duplicate that and use early returns instead:
1function Layout(props: { children: ReactNode }) {2 return (3 <Card>4 <CardHeading>Welcome 👋</CardHeading>5 <CardContent>{props.children}</CardContent>6 </Card>7 )8}9
10export function ShoppingList() {11 const { data, isPending } = useQuery(/* ... */)12
13 if (isPending) {14 return (15 <Layout>16 <Skeleton />17 </Layout>18 )19 }20
21 if (!data) {22 return (23 <Layout>24 <EmptyScreen />25 </Layout>26 )27 }28
29 return (30 <Layout>31 {data.assignee ? <UserInfo {...data.assignee} /> : null}32 {data.content.map((item) => (33 <ShoppingItem key={item.id} {...item} />34 ))}35 </Layout>36 )37}
Early returns are great for representing different states of a component because they can achieve a couple of things for us:
Reduced cognitive load
They show a clear path for developers to follow. Nothing is nested. Like async/await
, it becomes easier to reason about when reading top-down. Every if statement with a return represents one state the user can see. Notice how we've also moved the data.assignee
check into the last branch. That's because it's the only one where we actually want to render the UserInfo
. That wasn't clear in the previous version.
Easy to extend
We can now also add more conditions, like error handling, without having to fear that we're breaking other states. It becomes as simple as dropping another if statement into our code.
Better type inference
Notice how the last check for data
is just gone? That's because TypeScript knows that data
must be defined after we've handled the if (!data)
case. TypeScript can't help us if we only conditionally render something.
Layout duplication
I know some people are concerned about the duplication of rendering the <Layout>
component in each branch. I think they are focusing on the wrong thing. The duplication is not only fine, it will also help the component evolve better in case there might be a slight differentiation. For example, let's add a title
property from our data
to the heading:
1function Layout(props: { children: ReactNode; title?: string }) {2 return (3 <Card>4 <CardHeading>Welcome 👋 {props.title}</CardHeading>5 <CardContent>{props.children}</CardContent>6 </Card>7 )8}9
10export function ShoppingList() {11 const { data, isPending } = useQuery(/* ... */)12
13 if (isPending) {14 return (15 <Layout>16 <Skeleton />17 </Layout>18 )19 }20
21 if (!data) {22 return (23 <Layout>24 <EmptyScreen />25 </Layout>26 )27 }28
29 return (30 <Layout title={data.title}>31 {data.assignee ? <UserInfo {...data.assignee} /> : null}32 {data.content.map((item) => (33 <ShoppingItem key={item.id} {...item} />34 ))}35 </Layout>36 )37}
This would've been another top-level condition to mentally parse in the old version. Just note that adding more conditions to the Layout
component might indicate that it's the wrong abstraction. At this point, it's probably best to go to the drawing board again.
Learnings
Maybe this post is more about early returns than it is about component composition. I think it's about both. In any case, it's about avoiding conditional renderings for mutually exclusive states. We can't do that without component composition, so make sure to not skip the drawing board. It's your best friend.
That's it for today. Feel free to reach out to me on bluesky if you have any questions, or just leave a comment below. ⬇️