FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·093·10 MIN READ

093: Conditional Rendering

TOPICS COVERED: Conditional Rendering

Learning objective

Outcomes

You will model UI states explicitly and choose among JSX branches with early returns, ternaries, and &&. The examples cover loading, error, empty, and ready states.

By the end, you should be able to choose a conditional that remains readable and make sure every normal application state produces useful, accessible output.

Prerequisites

Complete 092 first. You should be comfortable mapping a list with stable IDs and deriving 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 an ordinary JavaScript calculation. Given different inputs, a component can return different JSX. A useful way to reason about this is to treat the UI as a small state machine, not as a collection of exceptional cases added after the “real” screen is finished.

  • Branch: Choosing between JSX alternatives based on a condition. — Source: React: Conditional rendering
  • Early return: Returning different JSX before the main body for a guard case. — Source: React: Conditional rendering
  • Ternary: cond ? a : b, an inline selection between two JSX results. — Source: MDN: Conditional operator
  • Logical AND: &&, which renders JSX only when its left side is truthy. — Source: MDN: Logical AND
  • Empty state: Deliberate UI shown when a list or other data set is empty instead of leaving a blank area. — Source: React: Conditional rendering
  • Impossible state: A contradictory combination such as loading and success at the same time (course term).

For request-driven UI, an app might use status: 'idle' | 'loading' | 'success' | 'error'. One status value usually prevents contradictory boolean combinations such as isLoading=true alongside 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 by hand and reach each branch. Early returns are a good fit here because loading and error replace the main screen region. They also keep the component from turning into a deeply nested ternary.

Choosing conditional syntax

Use an ordinary if before return when a branch is a major state or needs more than a small inline decision:

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

Use a ternary when there are exactly two inline alternatives:

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

Use && only when the false case should render nothing at all:

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

There is a small but common trap with numeric zero:

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

React renders numbers as text. Therefore, when tasks.length is 0, the first expression produces a visible 0. Make the condition explicitly boolean when that is what the UI decision means.

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 visible in the code and gives you a deliberate failure for an unexpected value. In this model, the empty state is reached only after a successful load. Do not use an empty array as a synonym for loading: an API can successfully return an empty array.

Empty messages should also reflect why the list is empty. “No tasks yet” points toward creating the first task, while “No tasks match ‘complete’” points toward clearing or changing a filter. Preserve the user's context and provide an action that can resolve the state when one is available.

Optional advanced: preserving component identity

Conditional branches affect positions in the rendered tree. When the same component type remains at the same position, React may preserve its state. When a different type replaces it, the replaced subtree's state resets. Do not define component functions inside branches. Use a deliberate key only when changing identity should reset state; it is not a general-purpose conditional-rendering tool.

Avoid a “boolean soup” interface:

jsx
<Screen isLoading hasError isEmpty />

Several combinations are either impossible or ambiguous. A discriminated status together with related data is easier to reason about. An advanced TypeScript curriculum could use a union to enforce those relationships, but the same model is useful in plain JavaScript.

Mistakes and debugging

  • Treating empty as an error: zero records can be a successful response.
  • Showing stale results below a new error without an intentional design: users may trust data that is no longer current.
  • Using nested ternaries: extract a component or use early returns.
  • Writing 0 && <Thing />: this displays zero.
  • Using condition || <Fallback /> when a valid value is falsy: the fallback can be selected unexpectedly.
  • Accidentally returning undefined 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.

Write a state table and manually reach every row. If a branch never appears, inspect the order of the conditions. For example, checking tasks.length === 0 before status === 'loading' can show an empty state while the request is still pending. React DevTools is useful here because it lets you inspect status and data together rather than guessing from the screen alone.

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; an error rendered on the server or already visible may only need ordinary semantic text. Never communicate state through color alone. Retry and recovery controls need labels that describe their action.

Avoid replacing the whole page rapidly for a small background update, since that can disrupt focus and the user's orientation. Keeping stable headings can help. Conditional calculations are cheap, so do not memoize simple booleans. Rendering only the required branch also avoids building hidden subtrees. CSS display: none and conditional rendering are not equivalent: hidden UI remains mounted, whereas 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 state manually.

Stretch: Add a retry callback and distinguish “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. The table keeps the status meaning separate from the data that happens to be present.

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 &&. Keep loading, error, empty, and ready distinct; derive conditions during render; and provide meaningful recovery where the user can act.

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 their state behavior can differ:

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 the variable names used in the JSX.

Avoid deeply nested ternaries:

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

For several meaningful 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 distinction becomes increasingly useful once server-state libraries and Suspense boundaries enter the application.


Deep dive: model UI states before writing branches

When a conditional component becomes messy, the problem is often not the JSX syntax. The component may be 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 represent combinations that should never occur:

text
loading = true
hasError = true
empty = true

A single status usually expresses mutually exclusive states more accurately:

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

with:

text
idle
pending
success
error

Later, TanStack Query already models server state this way, so do not recreate that state machine unnecessarily in your own component.

Boolean expressions

Good:

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

Be careful when the left operand is numeric:

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

When length is 0, React can render that number.

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 the component's host output, but the component still participates in rendering and can use 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 and present, the Hook call order changes between renders.

Place Hooks before conditional returns when the component must call them consistently, or split the component at a new 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 valid expressions, but deep branching is harder to audit and easier to get wrong when another state is added.

Branch identity

This preserves the same component type:

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

State can be preserved because the type and position remain the same.

This changes the type:

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

The subtree state resets when the component type changes.

Sometimes that reset is exactly what the UI needs.

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

Permission rendering

This client-side condition:

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

controls the user experience.

It is not a security boundary.

An attacker can call the API directly, so authorization on the server remains mandatory.

Keep this distinction explicit until it becomes automatic: hiding a control is not the same as preventing the operation.

Conditional loading anti-pattern

Do not show:

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

when missing data could mean any of the following:

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

Model those states separately so the UI does not mislabel a pending request or an authorization failure as an empty result.

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 example, the component does not need to independently reconcile upload progress, a success file, and an error message.

For more complex workflows, a state machine can be valuable. React itself does not require a state-machine library for ordinary conditional rendering.

Suspense changes some loading branches

Later, Suspense can move pending presentation for supported data or code dependencies to a boundary:

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

That changes where one loading branch is rendered; it does not eliminate 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 tends to accumulate independent booleans and overlapping checks:

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

That approach can produce overlapping UI and duplicate branches. Model the concerns in layers instead.

Authorization boundary

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

This controls client UX only. The API and server still need to authorize the request.

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 separate concepts:

text
permission
request lifecycle
domain state
action capability

That separation is what makes conditional rendering scale as the screen gains more rules.

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 the number of combinations would grow rapidly.

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

The same design principle will be useful later for:

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

The goal is not to minimize the number of if statements. The goal is to model the application's states accurately.

Reader page: /react/lesson/093/conditional-rendering