Module: React and Ecosystem
React and Ecosystem·093·8 MIN READ

093: Conditional Rendering

TOPICS COVERED: Conditional Rendering

Learning objective

Outcomes

You will model UI states explicitly and select JSX with early returns, ternaries, and &&, including loading, error, empty, and ready states.

I can choose a readable conditional and ensure every normal application state has useful, accessible output.

Prerequisites

Complete 092. You should be able to map a list with stable IDs and derive filtered data without mutating the source array.

Retrieval practice

  1. Why must a dynamic task key come from record identity?
  2. What does filter return?
  3. What happens when JSX evaluates to null or false?

Content to cover

ternary; &&; early returns; loading/empty/error states.

Terms and mental model

Rendering is ordinary JavaScript calculation. A component may return different JSX for different inputs. Treat UI states as a small state machine rather than as exceptional afterthoughts.

  • Branch: Choosing between JSX alternatives based on conditionals. — Source: React: Conditional rendering
  • Early return: Returning different JSX before the main body for guard cases. — Source: React: Conditional rendering
  • Ternary: cond ? a : b inline selection between two JSX results. — Source: MDN: Conditional operator
  • Logical AND: && rendering JSX only when the left side is truthy. — Source: MDN: Logical AND
  • Empty state: Deliberate UI shown when lists/data are empty rather than blank output. — Source: React: Conditional rendering
  • Impossible state: a contradictory combination such as loading and success at once (course term).

An app might have status: 'idle' | 'loading' | 'success' | 'error'. One status string often prevents contradictory booleans such as isLoading=true and hasError=true.

Beginner complete example

jsx
function TaskList({ tasks }) {
  return (
    <ul>
      {tasks.map((task) => <li key={task.id}>{task.title}</li>)}
    </ul>
  );
}

function TaskScreen({ status, tasks, errorMessage, onRetry }) {
  if (status === 'loading') {
    return <p role="status">Loading tasks…</p>;
  }

  if (status === 'error') {
    return (
      <section aria-labelledby="error-heading">
        <h2 id="error-heading">Tasks could not be loaded</h2>
        <p>{errorMessage}</p>
        <button type="button" onClick={onRetry}>Try again</button>
      </section>
    );
  }

  if (tasks.length === 0) {
    return (
      <section aria-labelledby="empty-heading">
        <h2 id="empty-heading">No tasks yet</h2>
        <p>Add a first task to plan your day.</p>
      </section>
    );
  }

  return (
    <section aria-labelledby="tasks-heading">
      <h2 id="tasks-heading">Today</h2>
      <TaskList tasks={tasks} />
      {tasks.some((task) => !task.completed) && <p>Keep going.</p>}
    </section>
  );
}

export default function App() {
  const tasks = [
    { id: 't1', title: 'Model UI states', completed: false },
    { id: 't2', title: 'Write empty content', completed: true },
  ];
  return (
    <main>
      <h1>Task Manager</h1>
      <TaskScreen
        status="success"
        tasks={tasks}
        errorMessage=""
        onRetry={() => console.log('retry')}
      />
    </main>
  );
}

Change status and tasks manually to test every branch. Early returns work well because loading and error replace the main screen region. They also avoid deeply nested ternaries.

Choosing conditional syntax

Use an ordinary if before return for multi-step or major branches:

jsx
if (status === 'loading') return <LoadingState />;

Use a ternary for exactly two inline alternatives:

jsx
<p>{task.completed ? 'Complete' : 'Open'}</p>

Use && only when the false branch should render nothing:

jsx
{isOverdue && <span>Overdue</span>}

Beware numeric zero:

jsx
{tasks.length && <TaskList tasks={tasks} />} // Can render 0.
{tasks.length > 0 && <TaskList tasks={tasks} />} // Clear boolean.

React renders numbers, so the first expression displays 0 when empty. Convert conditions to explicit booleans.

Intermediate: derived views and exhaustive states

jsx
function Results({ status, tasks, error, onRetry }) {
  switch (status) {
    case 'idle':
      return <p>Choose a project to view its tasks.</p>;
    case 'loading':
      return <p role="status">Loading tasks…</p>;
    case 'error':
      return (
        <div role="alert">
          <p>{error}</p>
          <button type="button" onClick={onRetry}>Retry loading tasks</button>
        </div>
      );
    case 'success':
      return tasks.length === 0
        ? <p>No tasks match the current filters.</p>
        : <TaskList tasks={tasks} />;
    default:
      throw new Error(`Unknown task status: ${status}`);
  }
}

A switch makes a finite status model explicit and catches unexpected values. The empty state occurs only after successful loading. Do not infer “loading” from an empty array: an API can legitimately return an empty array.

Different empty messages answer different user questions. “No tasks yet” suggests creation. “No tasks match ‘complete’” suggests clearing a filter. Preserve the user's context and offer an action that resolves the state.

Optional advanced: preserving component identity

Conditional branches affect tree positions. If the same component type occupies the same position, React may preserve its state. If a different type replaces it, that subtree's state resets. Avoid defining component functions within branches. Use a deliberate key only when changing identity should reset state, not as a general conditional rendering tool.

Avoid a “boolean soup” interface:

jsx
<Screen isLoading hasError isEmpty />

Several combinations are impossible or ambiguous. A discriminated status plus related data is easier to reason about. For an advanced TypeScript curriculum, a union can enforce the relationship, but plain JavaScript benefits from the same mental model.

Mistakes and debugging

  • Treating empty as error: zero records can be successful.
  • Showing stale list under a new error without intentional design: users may trust old data.
  • Nested ternaries: extract a component or use early returns.
  • 0 && <Thing />: displays zero.
  • condition || <Fallback /> with valid falsy values: can choose the wrong branch.
  • Returning undefined accidentally from a block-bodied component.
  • Leaving retry as a non-interactive span: use a real button.
  • Using an Effect to set isEmpty: derive tasks.length === 0 during render.

Create a state table and manually reach every row. If a branch never appears, inspect condition order. For example, checking tasks.length === 0 before status === 'loading' can incorrectly show empty while the request is pending. Use React DevTools to inspect status and data together.

Accessibility and performance

Loading text should be perceivable without trapping focus. role="status" is polite by default. Use role="alert" sparingly for a newly occurring error that needs immediate announcement; a server-rendered or already visible error may only need normal semantic text. Never rely on color alone. Retry and recovery actions need descriptive labels.

Avoid rapidly replacing the whole page for small background updates, which can disrupt focus and orientation. Keep stable headings when useful. Conditional calculations are cheap; do not memoize simple booleans. Rendering only the needed branch naturally avoids building hidden subtrees. CSS display: none and conditional rendering have different semantics: hidden UI remains mounted, while omitted UI does not exist and its component state may reset.

Practice

Add empty/loading/error states to a list.

Tiered exercises

Core: Implement loading, error, empty, and ready output from props. Reach each manually.

Stretch: Add a retry callback and separate “no tasks yet” from “no filtered matches.”

Challenge: Replace three contradictory booleans with one status value and write a table of allowed data for each status.

jsx
function TaskResults({ status, tasks, error, filter = 'all', onRetry }) {
  if (status === 'loading') return <p role="status">Loading tasks…</p>;
  if (status === 'error') {
    return (
      <div role="alert">
        <p>{error || 'An unexpected error occurred.'}</p>
        <button type="button" onClick={onRetry}>Retry loading tasks</button>
      </div>
    );
  }
  if (tasks.length === 0) {
    return filter === 'all'
      ? <p>No tasks yet. Add your first task.</p>
      : <p>No tasks match “{filter}”. Clear the filter.</p>;
  }
  return <ul>{tasks.map((task) => <li key={task.id}>{task.title}</li>)}</ul>;
}

export default function App() {
  return (
    <main>
      <h1>Task Manager</h1>
      <TaskResults
        status="success"
        tasks={[]}
        error=""
        filter="complete"
        onRetry={() => console.log('retry')}
      />
    </main>
  );
}

Allowed table: idle has no result requirement; loading has pending data; error has an error message; success has an array that may be empty. isEmpty is derived only for success.

Exit questions

  1. What problem does this concept solve?
  2. What is one common mistake?
  3. Can you explain the code without reading it line by line?

Recap

Conditional rendering is JavaScript selecting UI. Use early returns for major states, ternaries for two alternatives, and explicit booleans with &&. Model loading, error, empty, and ready separately, derive conditions during render, and provide meaningful recovery.

Official references

Interview questions

  1. How do loading, empty, error, and success differ semantically?
  2. Why can tasks.length && <List /> render an unwanted zero?
  3. When should a conditional subtree remain mounted instead of being removed?

Strong answer: Empty is a successful zero-result response, not a failure. Model mutually exclusive statuses, use explicit booleans, and choose mounting behavior based on whether preserving local state and focus matters.


2026 depth expansion: conditionals define tree identity

These two UIs look similar but have different state behavior:

jsx
{editing ? <Editor task={task} /> : <TaskView task={task} />}

and:

jsx
<section>
  {editing ? <Editor task={task} /> : <TaskView task={task} />}
</section>

State preservation depends on component type, key, and position in the rendered tree—not on variable names.

Avoid deeply nested ternaries:

jsx
return loading
  ? <Spinner />
  : error
    ? <ErrorView />
    : items.length === 0
      ? <Empty />
      : <List items={items} />;

For complex UI states, model the state explicitly and use early returns:

jsx
if (status === 'pending') return <LoadingView />;
if (status === 'error') return <ErrorView error={error} />;
if (items.length === 0) return <EmptyTasks />;

return <TaskList tasks={items} />;

This becomes increasingly important once server-state libraries and Suspense boundaries are introduced.


Deep dive: model UI states before writing branches

Many messy conditional components are actually missing a state model.

Instead of accumulating flags:

jsx
const [loading, setLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [empty, setEmpty] = useState(false);

you can create impossible states:

text
loading = true
hasError = true
empty = true

A single status may better express mutually exclusive states:

jsx
const [status, setStatus] = useState('idle');

with:

text
idle
pending
success
error

Later TanStack Query already models this for server state, so do not recreate it unnecessarily.

Boolean expressions

Good:

jsx
{tasks.length === 0 && <EmptyTasks />}

Be careful with numeric operands:

jsx
{tasks.length && <TaskList tasks={tasks} />}

When length is 0, React can render the number 0.

Prefer:

jsx
{tasks.length > 0 && <TaskList tasks={tasks} />}

Null rendering

A component can intentionally return null:

jsx
function PermissionNotice({ allowed }) {
  if (allowed) {
    return null;
  }

  return <p>You do not have access.</p>;
}

Returning null removes host output but the component itself can still participate in rendering and Hooks.

Do not conditionally skip Hook calls before a later render branch.

Wrong:

jsx
if (!user) return null;

const [open, setOpen] = useState(false);

If user changes between absent/present, Hook call order changes relative to renders.

Place Hooks before conditional returns if the component must call them consistently, or split the component boundary.

Early returns versus nested JSX

Readable:

jsx
if (query.isPending) {
  return <TaskSkeleton />;
}

if (query.isError) {
  return <TaskError error={query.error} />;
}

if (query.data.tasks.length === 0) {
  return <EmptyTasks />;
}

return <TaskList tasks={query.data.tasks} />;

Less readable:

jsx
return query.isPending ? ... : query.isError ? ... : ...

Nested ternaries are expressions, but deep branching is harder to audit.

Branch identity

This preserves same component type:

jsx
{compact
  ? <TaskList density="compact" />
  : <TaskList density="comfortable" />}

State can be preserved because the type/position is the same.

This changes type:

jsx
{mode === 'grid'
  ? <TaskGrid />
  : <TaskList />}

The subtree state resets across type changes.

Sometimes that is correct.

If both layouts should share state, move the shared state above the branch.

Permission rendering

Client condition:

jsx
{permissions.canDelete && (
  <DeleteButton task={task} />
)}

is UX.

It is not security.

An attacker can call the API directly. Server authorization remains mandatory.

This distinction should be repeated until it becomes automatic.

Conditional loading anti-pattern

Do not show:

jsx
if (!data) {
  return <p>No data.</p>;
}

when data being absent could mean:

  • not fetched yet;
  • fetch failed;
  • genuinely empty;
  • forbidden.

Model those states separately.

Worked example: state machine-like rendering

jsx
function UploadPanel({ upload }) {
  switch (upload.status) {
    case 'idle':
      return <UploadPicker />;

    case 'uploading':
      return (
        <UploadProgress
          progress={upload.progress}
          onCancel={upload.cancel}
        />
      );

    case 'success':
      return <UploadSuccess file={upload.file} />;

    case 'error':
      return (
        <UploadError
          error={upload.error}
          onRetry={upload.retry}
        />
      );

    default:
      throw new Error(`Unknown upload status: ${upload.status}`);
  }
}

The explicit state model makes impossible combinations harder to represent.

For more complex workflows, state machines can be valuable, but React does not require a state-machine library for ordinary conditionals.

Suspense changes some loading branches

Later, Suspense lets supported data/code dependencies move pending presentation to a boundary:

jsx
<Suspense fallback={<TaskSkeleton />}>
  <TaskPanel />
</Suspense>

That does not remove the need to model:

  • empty;
  • error;
  • permissions;
  • success variants.

Exercises

  1. Refactor three booleans into one status.
  2. Fix a count && <Component /> bug that renders 0.
  3. Demonstrate state preservation when a branch changes props but keeps component type.
  4. Demonstrate state reset when component type changes.
  5. Separate client permission rendering from server authorization logic.
  6. Refactor a deeply nested ternary into early returns or a switch.

Mastery check

You should be able to explain:

  • how conditional rendering affects component identity;
  • why loading, empty, and error must be distinct;
  • why client permission checks are not security;
  • why deep nested ternaries are often a design smell;
  • when null is appropriate.

Production case study: permissions, loading, and empty state without contradictory branches

Suppose a project screen has all of these rules:

  • user must have project:view;
  • project request can be pending/error/success;
  • success may contain zero tasks;
  • manager sees an Add button;
  • archived project shows a read-only banner.

A fragile implementation often accumulates booleans:

jsx
if (loading) ...
if (!allowed) ...
if (error) ...
if (!tasks.length) ...
if (archived) ...

with overlapping UI and duplicated branches.

Model it in layers.

Authorization boundary

jsx
if (!permissions.canViewProject) {
  return <ForbiddenProject />;
}

This is client UX only; API/server still authorizes.

Resource lifecycle

jsx
if (projectQuery.isPending) {
  return <ProjectSkeleton />;
}

if (projectQuery.isError) {
  return <ProjectLoadError error={projectQuery.error} />;
}

Successful domain state

jsx
const { project, tasks } = projectQuery.data;

return (
  <ProjectLayout>
    {project.archived && (
      <p role="status">
        This project is archived and read-only.
      </p>
    )}

    <ProjectHeader project={project}>
      {!project.archived && permissions.canCreateTask && (
        <NewTaskButton />
      )}
    </ProjectHeader>

    {tasks.length === 0
      ? <EmptyProjectTasks archived={project.archived} />
      : <TaskList tasks={tasks} />}
  </ProjectLayout>
);

The branches now correspond to different concepts:

text
permission
request lifecycle
domain state
action capability

That separation makes conditional rendering scalable.

Why not one mega-status?

You could encode every combination as:

text
forbidden
loading
load_error
archived_empty
archived_with_tasks
active_empty_manager
active_empty_viewer
...

but that explodes combinations.

Use one status for mutually exclusive states within one concern, then compose independent concerns.

This is the same design principle you will use later for:

  • query state;
  • form state;
  • route state;
  • mutation state.

The goal is not fewer if statements. The goal is accurate state modeling.