093: 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
- Why must a dynamic task key come from record identity?
- What does
filterreturn? - What happens when JSX evaluates to
nullorfalse?
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
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:
if (status === 'loading') return <LoadingState />;
Use a ternary for exactly two inline alternatives:
<p>{task.completed ? 'Complete' : 'Open'}</p>
Use && only when the false branch should render nothing:
{isOverdue && <span>Overdue</span>}
Beware numeric zero:
{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
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:
<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
undefinedaccidentally from a block-bodied component. - Leaving retry as a non-interactive span: use a real button.
- Using an Effect to set
isEmpty: derivetasks.length === 0during 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.
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
- What problem does this concept solve?
- What is one common mistake?
- 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
- React: Conditional Rendering
- React: Reacting to Input with State
- MDN: Conditional operator
- MDN: ARIA live regions
Interview questions
- How do loading, empty, error, and success differ semantically?
- Why can
tasks.length && <List />render an unwanted zero? - 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:
{editing ? <Editor task={task} /> : <TaskView task={task} />}
and:
<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:
return loading
? <Spinner />
: error
? <ErrorView />
: items.length === 0
? <Empty />
: <List items={items} />;
For complex UI states, model the state explicitly and use early returns:
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:
const [loading, setLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [empty, setEmpty] = useState(false);
you can create impossible states:
loading = true hasError = true empty = true
A single status may better express mutually exclusive states:
const [status, setStatus] = useState('idle');
with:
idle pending success error
Later TanStack Query already models this for server state, so do not recreate it unnecessarily.
Boolean expressions
Good:
{tasks.length === 0 && <EmptyTasks />}
Be careful with numeric operands:
{tasks.length && <TaskList tasks={tasks} />}
When length is 0, React can render the number 0.
Prefer:
{tasks.length > 0 && <TaskList tasks={tasks} />}
Null rendering
A component can intentionally return null:
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:
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:
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:
return query.isPending ? ... : query.isError ? ... : ...
Nested ternaries are expressions, but deep branching is harder to audit.
Branch identity
This preserves same component type:
{compact
? <TaskList density="compact" />
: <TaskList density="comfortable" />}
State can be preserved because the type/position is the same.
This changes type:
{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:
{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:
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
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:
<Suspense fallback={<TaskSkeleton />}>
<TaskPanel />
</Suspense>
That does not remove the need to model:
- empty;
- error;
- permissions;
- success variants.
Exercises
- Refactor three booleans into one status.
- Fix a
count && <Component />bug that renders0. - Demonstrate state preservation when a branch changes props but keeps component type.
- Demonstrate state reset when component type changes.
- Separate client permission rendering from server authorization logic.
- 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
nullis 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:
if (loading) ...
if (!allowed) ...
if (error) ...
if (!tasks.length) ...
if (archived) ...
with overlapping UI and duplicated branches.
Model it in layers.
Authorization boundary
if (!permissions.canViewProject) {
return <ForbiddenProject />;
}
This is client UX only; API/server still authorizes.
Resource lifecycle
if (projectQuery.isPending) {
return <ProjectSkeleton />;
}
if (projectQuery.isError) {
return <ProjectLoadError error={projectQuery.error} />;
}
Successful domain state
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:
permission request lifecycle domain state action capability
That separation makes conditional rendering scalable.
Why not one mega-status?
You could encode every combination as:
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.
