Skip to main content
Zubin KhavarianZubin Khavarian

switch(true) in React: First True Wins

· Last updated · 5 min read · #react #javascript

Editorial illustration of a walnut plate with five toggle switches; two are flipped on, but only the first of them lights up mint under a glowing bulb while the later one stays dim lilac, every edge drawn in wobbly ink.

switch(true) is an ordered list of conditions, and the first one that’s true runs.

It looks like a trick the first time you see it, but it’s plain JavaScript and MDN documents it (opens in a new tab). A switch compares its value to each case with ===. Hand it true, make every case an expression, and the first expression that evaluates to true wins.

switch (true) {
  case condition1:
    return <A />;
  case condition2:
    return <B />;
  default:
    return <C />;
}

That’s an if / else if / else chain with different punctuation. In a React render you return from every case, so fall-through never gets a chance to bite. The real question is when the different punctuation is worth it.

When is it better than if?

When you’re branching on more than one value, the branches have a precedence order, and there are enough of them that a ladder of ifs starts to feel like a maze.

Permissions are the classic case. Archived beats everything, owner beats editor, and an editor holding the lock beats an editor who isn’t. That’s really a table, and switch(true) lets the code look like one.

function ProjectActions({ user, project }: Props) {
  switch (true) {
    case project.archived:
      return <ReadOnlyBanner />;
    case user.role === "owner":
      return <OwnerActions project={project} />;
    case user.role === "editor" && project.lockedBy === user.id:
      return <EditorActions project={project} />;
    case user.role === "editor":
      return <RequestLockButton project={project} />;
    case user.role === "viewer":
      return <ViewerActions project={project} />;
    default:
      return null;
  }
}

Read it top to bottom and you can see the whole policy at once. Here’s what happens for an editor who holds the lock:

user: editor, holds the lock · project: not archivedproject.archivedfalse<ReadOnlyBanner />role === "owner"false<OwnerActions />role === "editor" && lockedBy === metrue<EditorActions />role === "editor"<RequestLockButton />role === "viewer"<ViewerActions />never checked
An editor holding the lock falls through two false cases and stops at the first true one. The rest never run.

project.archived short-circuits everything below it. If you tried to express this as a lookup map, you’d end up with a guard if on top of the map anyway.

With only two or three branches, I don’t bother. An if and a return are clearer, and the table isn’t worth it yet.

What about ranges?

This is where it really shines. A lookup map can’t express >= 70, and switch(true) can.

function PasswordStrengthLabel({ score }: { score: number }) {
  switch (true) {
    case score >= 90:
      return <Label tone="green">Strong</Label>;
    case score >= 70:
      return <Label tone="lime">Good</Label>;
    case score >= 40:
      return <Label tone="amber">Weak</Label>;
    default:
      return <Label tone="red">Very weak</Label>;
  }
}

The order is doing real work here. Each case only has to say where its band starts, because everything above it has already been claimed. Swap the order and the code still compiles, but it quietly means something else:

case score >= 90 → >= 70 → >= 40 → defaultcase score >= 40 firstVery weakWeakGoodStrong0407090100Very weakWeak040100Good and Strong never match
Same four cases, two orders. Check >= 40 first and Good and Strong become unreachable.

The same pattern works for HTTP status families, file size buckets and anything else ordinal. An if ladder can do it too; switch(true) is for when you want the thresholds laid out as a list you can scan.

When should you not use it?

When one value picks one thing. Use a lookup.

const ICONS = {
  success: CheckIcon,
  error: XIcon,
  warning: AlertIcon,
  info: InfoIcon,
} as const;

function StatusIcon({ kind }: { kind: keyof typeof ICONS }) {
  const Icon = ICONS[kind];
  return <Icon />;
}

Add a new kind and TypeScript asks for a new key. You can’t get the order wrong, because there isn’t one. A Record<Kind, ...> is even louder about missing keys, so reach for that when the keys are a union you own.

When there’s one discriminant. Use switch (status) or switch (action.type).

TypeScript treats a switch on a union of string literals as exhaustive. Handle every member and you don’t need a default; miss one and the build fails, through noImplicitReturns or an assertNever in the default.

switch(true) doesn’t get that. Even if you write a case for every s.kind === "...", TypeScript still wants a default or a trailing return, because the thing being switched on is the boolean true, not your union. You’ve thrown away the exhaustiveness check that made switch (action.type) worth writing.

So don’t use it in a reducer. Nested ifs inside case "DISMISS": are fine there; they keep the discriminant.

When a case isn’t a boolean. case 1 < 2 matches, because 1 < 2 evaluates to true. case "yes" never matches, because true === "yes" is false. It’s still just ===.

Does TypeScript still narrow?

Yes, since TypeScript 5.3, on comparisons it can see. case x === null narrows x inside that case, and later cases know it isn’t null. What it can’t do is invent facts: case project.archived doesn’t tell TypeScript anything about user.

The same limit applies to independent flags. If isLoading and isError are both false, TypeScript still can’t conclude that data exists, because nothing ties those booleans to it. An if ladder has exactly the same hole. If you write query.data! after a pile of flag checks, that ! is a bet about your runtime, not something the switch earned.

When you have a real discriminant, switch on that instead and let TypeScript rule out undefined for you.

What else bites you?

Fall-through. It’s the language default. MDN’s own switch(true) example stacks cases on purpose so that a square also counts as a rectangle. In a render function you return, so you never notice. In a reducer, forget a break and two cases run. no-fallthrough (opens in a new tab) is in ESLint’s recommended set; leave it on.

default in the middle. default doesn’t have to come last, and putting it in the middle is a great way to get surprise fall-through. default-case-last (opens in a new tab) exists for exactly this.

Shared scope. case clauses are labels, not blocks, so two const message declarations in two different cases collide. Wrap the case body in { } if you declare anything.

And if a case grows past a few lines, pull it out into a function. The whole point is that the table stays a table.

What should you reach for?

two or three branches?one value decides it?just key → thing?if / returnnarrowing, no ceremonyswitch (true)precedence and rangeslookup mapno order to get wrongswitch (value)exhaustive over a unionyesnonoyesyesno
switch(true) is one branch of four. It earns its place when no single value decides.
  • if / return when there are two or three branches and you care more about narrowing than about a table.
  • A lookup map when you’re pairing one key with one thing.
  • switch (value) when there’s a discriminant you want TypeScript to check exhaustively.
  • switch(true) when precedence is the point and no single value decides the answer.

That first case covers most of React. What’s left over is ordered conditions with no single key, and that’s where switch(true) is genuinely the nicest thing to read. Use it there, and leave it alone everywhere else.

Get new posts by email

One short email when a post goes out, about once a week. Unsubscribe any time.

Or follow along:RSSGitHubX

Zubin Khavarian, Software EngineerWritten by
READ~/blog/switch-true-the-stylish-way-to-simplify-complex-conditionals-in-react~5 min0%
zubin@zubin.dev: man keys

// go to

  • ghhome
  • gwwriting
  • gaapps
  • gpprojects
  • goopen source
  • gnnow
  • guuses

// move

  • jscroll down
  • kscroll up
  • ggtop of page
  • Gbottom of page
  • }next section (posts)
  • {previous section (posts)
  • ]newer post
  • [older post

// do

  • ⌘Ksearch everything
  • /search, too
  • ttoggle light / dark
  • ?this sheet
  • escclose