Why Early Returns Make React Component Composition Cleaner
The article explains how treating UI as composable components, avoiding tangled conditional rendering, and using early returns can reduce cognitive load, improve type inference, and make React code easier to extend and maintain.
This article, translated from "Component Composition is great btw," discusses why component composition is the most powerful feature of React and how to handle conditional rendering effectively.
When first learning React, developers focus on the virtual DOM, one‑way data flow, and JSX, but the real advantage lies in composing components into larger ones. Over time, developers realize that grouping related logic, style, and markup together improves code cohesion.
However, many applications fall into the trap of conditional rendering, which can make components hard to evolve.
Conditional Rendering
In JSX you can render components conditionally. The following example renders a shopping list and optionally shows user information.
<code>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>
)
}</code>If no assignee is provided, the user‑info part is omitted.
Conditionally Rendering Multiple States
When the component becomes self‑contained by fetching its own data, more states appear (loading, empty, error). The diff below adds a loading skeleton and handles the empty state.
<code>export function ShoppingList() {
const { data, isPending } = useQuery(/* ... */)
return (
<Card>
<CardHeading>Welcome 👋</CardHeading>
<CardContent>
{data?.assignee ? <UserInfo {...data.assignee} /> : null}
{isPending ? <Skeleton /> : null}
{data ? (
data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))
) : (
<EmptyScreen />
)}
</CardContent>
</Card>
)
}</code>This introduces a bug: also appears during the loading state. Adding another condition fixes it.
<code>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} />
))}
</CardContent>
</Card>
)
}</code>Even with these fixes, the JSX becomes cluttered with many ternary expressions, increasing cognitive load.
Back to the Drawing Board
Following the React docs, the UI should be broken into visual boxes. The diagram below illustrates shared layout (red) versus state‑specific content (blue).
Layout Duplication Issue
Extract the shared layout into a
Layoutcomponent that accepts children.
<code>function Layout(props: { children: ReactNode }) {
return (
<Card>
<CardHeading>Welcome 👋</CardHeading>
<CardContent>{props.children}</CardContent>
</Card>
)
}
export function ShoppingList() {
const { data, isPending } = useQuery(/* ... */)
return (
<Layout>
{data?.assignee ? <UserInfo {...data.assignee} /> : null}
{isPending ? <Skeleton /> : null}
{!data && !isPending ? <EmptyScreen /> : null}
{data && data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))}
</Layout>
)
}</code>Although this removes some duplication, the conditional logic is still tangled.
Early Return
Since the only JSX needed is the
<Layout>call, we can move the conditional checks out of JSX and return early.
<code>function Layout(props: { children: ReactNode }) {
return (
<Card>
<CardHeading>Welcome 👋</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>
{data.assignee ? <UserInfo {...data.assignee} /> : null}
{data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))}
</Layout>
)
}</code>Early returns provide three main benefits:
Reduced cognitive load: each
ifbranch clearly represents a UI state, making the flow top‑to‑bottom like async/await.
Easy extensibility: new states (e.g., error handling) can be added with additional
ifblocks without affecting existing ones.
Better type inference: after handling the
!datacase, TypeScript knows
datais defined, improving autocomplete and safety.
Layout Repetition Problem
Repeating
<Layout>in each branch is acceptable because it isolates subtle differences and keeps the component flexible. Adding a
titleprop demonstrates this:
<code>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>
)
}</code>Adding more conditions to the layout may indicate a flawed abstraction, suggesting a redesign.
Overall, the piece emphasizes that early returns, combined with thoughtful component composition, help avoid mutually exclusive conditional rendering and keep UI code maintainable.
KooFE Frontend Team
Follow the latest frontend updates
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.