Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Conditional Rendering for Absent, Loading, and Empty Data

Welcome back. Last lesson, your search input and checkbox updated local state, but the page displayed the same kind of interface throughout. Now you’ll use state to decide which interface appears.

A user directory needs to distinguish “no data has been requested” from “data is loading” and “the request succeeded but returned no users.” Those situations can all leave you with no rows on screen, yet they call for different messages. This lesson uses a small, manually controlled preview to make those distinctions visible; fetching data comes later. Allow about 40 minutes, including the resources and a change to your Vite project.


Conditional rendering chooses the interface

React components can use ordinary JavaScript conditions to decide what JSX to return. An if statement works well when entire sections differ. A ternary expression suits a small either-or choice, like the “Yes” or “No” text you rendered for your checkbox last lesson. Use && when something should appear only if a condition is true.

Watch the relevant demonstrations in React 19 Tutorial - 10 - Conditional Rendering from Codevolution. Its example uses JavaScript rather than TypeScript, but the JSX choices work the same way in your project.

React 19 Tutorial - 10 - Conditional Rendering

Codevolution compares three ways to select JSX: separate returns, an inline either-or choice, and a show-or-hide condition. Watch for what each expression returns when its condition is false.

Watch separate returns for substantially different interfaces and for return null. Then watch the ternary for a choice within shared markup. Finish with show or hide, especially the warning near the end about using a numeric length on the left of &&.

One useful distinction: returning null from a component tells React to render nothing for that component. A null data value, by contrast, can mean that data is absent. You may still want the component to render an explanation of that absence. Do not let the shared word “null” obscure those different decisions.


Model the situation before choosing JSX

Suppose a directory currently has no user records to show. At least three explanations are possible:

Situation What the interface should communicate
No request has been made The directory has not been loaded yet.
A request is in progress The directory is loading.
A request succeeded with an empty array There are no user records.

An empty array is valid data, not evidence that no request happened. Similarly, loading is not an empty result: the result is not known yet. This is much like keeping a missing value distinct from an observed count of zero in a dataset. The distinction matters because each conveys different information to whoever uses the interface.

A TypeScript discriminated union lets one status value identify which other fields are available:

type User = {
  id: string
  name: string
}

type DirectoryState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User[] }

Here, idle represents absence before a request. Only success has data, and its array may contain zero or more users. A state such as { status: 'loading', data: [] } is not part of this model, so it cannot misleadingly present an unfinished request as an empty result.

React’s Using TypeScript guide introduces this pattern with a request-state union. Read the useState discussion after its boolean enabled example.

Using TypeScript

The React documentation shows why an explicit type argument is useful when state can take several shapes. Read through its request-state example, then compare its general-purpose data field with the User[] field above.

In the useState discussion, start at the sentence explaining when to type state explicitly. Continue through the Status and RequestState examples; stop after the requestState initialization. Focus on how status determines which fields belong to each case.

The guide uses any for its illustrative success data. In this directory, User[] gives TypeScript more useful information. An error would also deserve its own state rather than being treated as an empty result; you’ll build fuller request and error handling later in the course.


Render each state deliberately

Replace src/App.tsx with the following. It keeps the controlled search input and checkbox from last lesson, while adding buttons that simulate different directory states. The buttons do not make network requests, and the search controls do not filter these preview results yet.

import { useState } from 'react'

type User = {
  id: string
  name: string
}

type DirectoryState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User[] }

const sampleUsers: User[] = [
  { id: 'u1', name: 'User A' },
  { id: 'u2', name: 'User B' },
]

function DirectoryResult({ request }: { request: DirectoryState }) {
  if (request.status === 'idle') {
    return <p>Directory data has not been requested.</p>
  }

  if (request.status === 'loading') {
    return <p>Loading directory data…</p>
  }

  if (request.data.length === 0) {
    return <p>No user records were returned.</p>
  }

  return <p>{request.data.length} user records are available.</p>
}

function App() {
  const [query, setQuery] = useState('')
  const [activeOnly, setActiveOnly] = useState(false)
  const [request, setRequest] = useState<DirectoryState>({
    status: 'idle',
  })

  return (
    <main>
      <h1>User directory controls</h1>

      <label htmlFor="user-query">Search by name or email</label>
      <input
        id="user-query"
        type="search"
        value={query}
        onChange={(event) => setQuery(event.currentTarget.value)}
      />

      <label>
        <input
          type="checkbox"
          checked={activeOnly}
          onChange={(event) => setActiveOnly(event.currentTarget.checked)}
        />
        Active accounts only
      </label>

      <p>Search text: {query}</p>
      <p>Active only: {activeOnly ? 'Yes' : 'No'}</p>

      <h2>Preview directory states</h2>
      <div>
        <button type="button" onClick={() => setRequest({ status: 'idle' })}>
          Not requested
        </button>
        <button type="button" onClick={() => setRequest({ status: 'loading' })}>
          Show loading
        </button>
        <button
          type="button"
          onClick={() => setRequest({ status: 'success', data: [] })}
        >
          Load empty result
        </button>
        <button
          type="button"
          onClick={() => setRequest({ status: 'success', data: sampleUsers })}
        >
          Load sample result
        </button>
      </div>

      <section aria-labelledby="result-heading">
        <h2 id="result-heading">Directory data preview</h2>
        <DirectoryResult request={request} />
      </section>
    </main>
  )
}

export default App

Follow the logic inside DirectoryResult. The first two returns handle states without data. Once execution reaches request.data.length, TypeScript can tell that request.status must be 'success', so data exists. A length of zero gets its own message; a positive length gets the record count. The component never has to guess what an absent array means.

Notice also that DirectoryResult returns only the changing content. The heading and section remain in App, so they do not need to be repeated in every branch. Meanwhile, the small activeOnly ? 'Yes' : 'No' choice remains a good use of a ternary. Neither the status checks nor the empty-result check would become clearer as a chain of nested ternaries.


Check the distinctions, not just the syntax

Run the page and select each preview button. You should see four outcomes: not requested, loading, no records returned, and two records available. Type in the search box and toggle the checkbox as well; those controls should still update their displayed values independently of the preview. Then run:

npm run typecheck

Two shortcuts are tempting here but would erase information:

// Treats absent data and an empty result as the same situation:
if (!users?.length) {
  return <p>No users</p>
}

// When the length is zero, React may render the digit 0:
{users.length && <p>Users are available</p>}

In the first snippet, users might be null or undefined or an empty array; the message cannot tell you which. If a separate loading flag exists, it must be checked separately too. In the second, JavaScript evaluates 0 && ... to 0, which React can display. Use an explicit boolean condition such as users.length > 0 for show-or-hide JSX. For this directory preview, the status branches and length === 0 check express the intent more clearly.


What to carry forward

Conditional rendering is a choice about meaning, not merely JSX syntax. Use separate returns for substantially different states, a ternary for a compact either-or choice, and && for an optional element with a boolean condition. Keep “not requested,” “loading,” and “success with no records” distinct; an empty array is a completed result.

Next, you’ll render a collection of typed records rather than just its count, and see why stable keys matter when React updates that collection.

Can't find a good explanation? Sign up and we'll make it for you

Sign up