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.
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>
)
}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.
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>
)
}This introduces a bug: also appears during the loading state. Adding another condition fixes it.
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>
)
}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 Layout component that accepts children.
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>
)
}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.
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>
)
}Early returns provide three main benefits:
Reduced cognitive load: each if branch 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 if blocks without affecting existing ones.
Better type inference: after handling the !data case, TypeScript knows data is 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 title prop demonstrates this:
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>
)
}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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
