Conditional Rendering Patterns
Basic ternaries and && are enough for one or two branches, but real screens have many states: loading, error, empty, success, and a fistful of status enums. Cramming all of that into nested ternaries produces JSX that nobody can read. The patterns on this page—guard returns, lookup maps, and component extraction—keep conditional UI flat, declarative, and easy to extend as states multiply.
Guard returns for sequential states
When a component must pass through several mutually exclusive states before reaching its “happy path,” handle each one with an early return at the top. Each guard answers one question and exits, so by the time you reach the final return you know every precondition is satisfied. This reads top-to-bottom like a checklist and never nests.
function UserCard({ status, error, user }) {
if (status === "loading") return <Spinner />;
if (status === "error") return <ErrorBanner message={error} />;
if (!user) return <EmptyState label="No user found" />;
return (
<article className="card">
<h2>{user.name}</h2>
<p>{user.email}</p>
</article>
);
}
Compare that to the equivalent nested ternary—status === "loading" ? <Spinner /> : status === "error" ? ... : ...—which forces the reader to balance parentheses in their head. Guard returns scale linearly: adding a fourth state is one more line, not another layer of nesting.
Guards must come before any conditional
return null, but after all your hooks. Hooks must run in the same order on every render, so never put areturnabove auseStateoruseEffectcall.
Mapping a value to a component with a lookup object
When a single variable selects one of N components, a lookup object is dramatically cleaner than a chain of if/else if or a switch. You define a plain object whose keys are the possible values and whose values are the components (or elements), then index into it. Adding a case is a one-line edit, and the data structure is trivial to test.
const TAB_VIEWS = {
overview: OverviewPanel,
activity: ActivityPanel,
settings: SettingsPanel,
};
function Tabs({ active }) {
const Panel = TAB_VIEWS[active] ?? NotFoundPanel;
return <Panel />;
}
Because Panel is a capitalized variable, JSX treats it as a component and renders it. The ?? NotFoundPanel fallback handles any value missing from the map, so an unexpected key never crashes the UI. If each branch needs different props, store a render function instead of a bare component:
const STATUS_BADGE = {
active: () => <span className="badge badge--green">Active</span>,
paused: () => <span className="badge badge--gray">Paused</span>,
banned: (name) => <span className="badge badge--red">{name} banned</span>,
};
function Badge({ status, name }) {
const render = STATUS_BADGE[status] ?? (() => null);
return render(name);
}
Rendering enums and status values
Status enums—order states, request lifecycles, permission levels—map perfectly onto the lookup pattern, but you often want both an icon and a label per value. Keep the presentation data in a config object and let the component stay dumb. This separates what each status looks like (data) from how it’s drawn (one piece of JSX).
const ORDER_STATES = {
pending: { label: "Pending", icon: "clock", tone: "warn" },
shipped: { label: "Shipped", icon: "truck", tone: "info" },
delivered: { label: "Delivered", icon: "check", tone: "ok" },
cancelled: { label: "Cancelled", icon: "x", tone: "error" },
};
function OrderStatus({ state }) {
const cfg = ORDER_STATES[state];
if (!cfg) return null;
return (
<span className={`status status--${cfg.tone}`}>
<Icon name={cfg.icon} /> {cfg.label}
</span>
);
}
Output (when state is "shipped"):
🚚 Shipped
To add a refunded state, you add one entry to ORDER_STATES—no JSX changes. This is the single biggest readability win over a switch that interleaves data and markup.
Avoiding deeply nested ternaries
A two-way ternary is fine; a three-way one is a smell. When you find yourself writing a ? b : c ? d : e, refactor using one of the table’s strategies below. The goal is that no return contains more than one ?.
| Situation | Anti-pattern | Better pattern |
|---|---|---|
| Several exclusive states | Nested ternary | Guard returns |
| Value selects a component | switch in JSX | Lookup object |
| Status with icon + label | Inline if/else | Config object |
| Big branch grows in markup | Inline JSX | Extracted component |
The last row matters most. When a branch’s JSX exceeds a few lines, pull it into its own component. The parent then becomes a clean dispatcher, and each branch gains its own name, props, and testability.
function Notification({ kind, payload }) {
if (kind === "mention") return <MentionNotice {...payload} />;
if (kind === "reply") return <ReplyNotice {...payload} />;
return <GenericNotice {...payload} />;
}
Best Practices
- Use guard returns for sequential, mutually exclusive states—loading, error, empty—before the happy path.
- Keep all hook calls above your conditional returns so hook order stays stable.
- Replace long
if/else ifchains with a lookup object keyed by the deciding value. - Always provide a fallback (
?? Fallback) when indexing a lookup map with dynamic data. - Store status presentation as a config object so adding a state is a data change, not a JSX change.
- Never nest ternaries beyond one level; refactor to guards, maps, or extracted components.
- Extract any branch whose JSX grows past a few lines into a named child component.