React – Conditional Rendering

June 24, 20264 min readUpdated 8/18/2026

JSX has no if. It is an expression, and statements do not go inside expressions. So conditionals are done with the tools JavaScript already has for producing a value — and there are four worth knowing, each best in a different place.

1. An early return

When the whole component should render something else, do not nest — return early. Ordinary if statements are fine here, because you are above the return:

export function ProtectedRoute({ children, requireAdmin = false }: Props) {
  const { isAuthenticated, isAdmin, initialising } = useAuth();
  const location = useLocation();

  if (initialising) {
    return (
      <div className="text-center py-5">
        <Spinner animation="border" variant="danger" role="status">
          <span className="visually-hidden">Checking your session…</span>
        </Spinner>
      </div>
    );
  }

  if (!isAuthenticated) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  if (requireAdmin && !isAdmin) {
    return <Navigate to="/" replace />;
  }

  return <>{children}</>;
}

Three guards, then the happy path, and none of it indented. Compare that to the same logic as nested ternaries and the case makes itself.

The initialising guard is worth a second look, because leaving it out is a real bug: without it, refreshing the page on /admin renders one frame in which the stored token has not been checked yet, isAuthenticated is still false, and a perfectly valid admin gets bounced to the login screen.

2. &&, for "show this or nothing"

a && b evaluates to b when a is truthy, and to a when it is not. Since React renders nothing for false, null and undefined, that is a conditional:

{isAdmin && (
  <Nav.Link as={NavLink} to="/admin">
    Admin
  </Nav.Link>
)}

This is the most common form, and it has one trap. The left side must be a boolean. React skips false but it happily renders 0:

// Renders a literal "0" beside the Cart button when the cart is empty.
{totals.itemCount && <Badge>{totals.itemCount}</Badge>}

// Correct — the left side is now a boolean.
{totals.itemCount > 0 && (
  <Badge bg="light" text="dark" pill className="cart-badge">
    {totals.itemCount}
  </Badge>
)}

The same applies to items.length && … and any other count. Compare, or coerce with Boolean(…). This bug ships regularly because it only shows up in the empty state.

3. The ternary, for "this or that"

When there are two branches and both render something, ? : is the only thing that fits inside JSX:

<div className="product-thumb" aria-hidden="true">
  {isPizza ? '🍕' : '🥤'}
</div>

<Button type="submit" variant="primary" className="w-100" disabled={loading}>
  {loading ? 'Signing in…' : 'Sign in'}
</Button>

It scales up to whole blocks, though this is about as far as it should go before you extract a component:

{items.length === 0 ? (
  <div className="text-center text-muted py-5">
    <div className="display-6 mb-2">🍕</div>
    <p className="mb-0">Your cart is empty.</p>
  </div>
) : (
  <>
    <Stack gap={3} className="flex-grow-1 overflow-auto">
      {items.map((item) => (
        /* … one cart line … */
      ))}
    </Stack>
    {/* … totals and the Checkout button … */}
  </>
)}

Nested ternaries are where this stops being readable. Two levels is already too many; pull the decision above the return and assign to a variable, or split the component.

4. Returning null

A component may decide it renders nothing at all:

function VizTooltip({ active, payload, label }: Props) {
  if (!active || !payload?.length) return null;

  return (
    <div className="viz-tooltip">
      <div className="viz-tooltip-label">{label}</div>
      {/* … */}
    </div>
  );
}

Returning null is not an error and not a special case — it is a normal thing for a component to do. The component stays mounted and keeps its state; it just contributes no DOM.

Which one, when

SituationUse
The whole component renders something elseearly return
Show a thing, or nothing&&
Show one thing or anotherternary
Render nothing at allreturn null
More than two branchescompute above the return

That last row matters. Once there are three or more outcomes, work it out in ordinary JavaScript first and put a variable in the JSX:

// A lookup beats a chain of ternaries, and it is what the orders table actually does.
const STATUS_VARIANT: Record<OrderStatus, string> = {
  PENDING_PAYMENT: 'warning',
  PAID: 'primary',
  PREPARING: 'info',
  COMPLETED: 'success',
  CANCELLED: 'secondary',
};

<Badge bg={STATUS_VARIANT[order.status]}>
  {order.status.replace('_', ' ').toLowerCase()}
</Badge>

A note on loading and error states

Nearly every screen that fetches something has three of these stacked up, and it is worth writing them out explicitly rather than cleverly:

{loading && (
  <div className="text-center py-5">
    <Spinner animation="border" variant="danger" role="status">
      <span className="visually-hidden">Loading the menu…</span>
    </Spinner>
  </div>
)}

{error && (
  <Alert variant="danger" className="d-flex justify-content-between align-items-center">
    <span>{error}</span>
    <Button size="sm" variant="outline-danger" onClick={reload}>
      Try again
    </Button>
  </Alert>
)}

{!loading && !error && (
  <Row xs={1} sm={2} lg={4} className="g-4">
    {visibleProducts.map((product) => (
      <Col key={product.id}>
        <ProductCard product={product} onSelect={handleSelect} />
      </Col>
    ))}
  </Row>
)}

Three sibling conditions rather than one nested ternary. It is more lines and considerably easier to change — and the error branch offers a retry, which a spinner-or-content ternary has nowhere to put.

Next

Rendering Lists and Keys.