
switch(true): The Stylish Way to Simplify Complex Conditionals in React
switch(true) is an ordered list of boolean cases. The first one that is true runs.
MDN documents this 🔗. It is not a hack. switch compares with ===. You hand it true. Each case is an expression.
switch (true) {
case condition1:
return <A />;
case condition2:
return <B />;
default:
return <C />;
}
That is an if / else if / else chain with different punctuation. In a React render, you return from each case, so fall-through never happens.
So why bother?
When is this better than if?
When you are branching on more than one value, the branches have a precedence order, and the list is long enough that a ladder of ifs becomes a maze.
Admin beats editor beats viewer. Archived beats all of them. That is a table. switch(true) looks like a table.
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;
}
}
project.archived short-circuits everything else. Try stuffing that into a lookup map and you end up with a guard if on top anyway.
Two or three branches? I just write if and return. The table is not worth it yet.
What about ranges?
Lookup maps cannot express >= 70. 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>;
}
}
Order is the feature. Put >= 40 first and everything is “Weak.” Same pattern for HTTP families, file size buckets, anything ordinal.
An if ladder does this too. The switch is for when you want the thresholds in a scannable list.
When should you not use it?
One value, no ranges, no precedence. 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 kind and TypeScript wants a new key. You cannot get the order wrong, because there is no order.
A Record<Kind, ...> is even louder about holes. Use that when the set of keys is a union you own.
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 do not need a default. Miss one, and noImplicitReturns (or an assertNever in default) fails the build.
switch(true) does not get that. Compile a function that has a case for every s.kind === "...". It still needs a default, or a trailing return. The thing being switched on is the boolean true, not the union. You threw away the exhaustiveness that made switch (action.type) worth writing.
Don’t do this in a reducer. Nested ifs inside case "DISMISS": are the point. They keep the discriminant.
A case that is not a boolean. true === "yes" is false. case 1 < 2 works, because 1 < 2 is true. case "yes" does not match. That is just ===.
Does TypeScript still narrow?
Yes, on comparisons TypeScript can see.
case x === null narrows x in that case, and in later cases. case project.archived does not magically prove that user is an owner.
What it will not do is prove that data exists because isLoading and isError were false. Those flags are independent booleans. An if ladder has the same hole. If you write query.data! after a pile of flags, that ! is a bet about your runtime, not something switch earned.
If you have a real discriminant, switch on that, and let TypeScript throw out undefined.
What else bites you?
Fall-through is the language default. MDN’s own switch(true) example stacks predicates so a square also counts as a rectangle. In a render function you return, so you never see it. In a reducer, forget break and two cases run. no-fallthrough 🔗 is on in ESLint’s recommended set. Leave it on.
default-case-last 🔗 exists because default does not have to be last, and putting it in the middle is how you get surprise fall-through. Put it last.
case clauses are labels, not blocks. Two const message declarations in two cases collide. Wrap the case body in { } if you declare anything.
If a case grows past a few lines, extract a function and call it. The table should stay a table.
What should you reach for?
switch(true) when precedence is the point and a lookup cannot express the condition.
A map when you are pairing one key with one component.
switch (value) when you have a discriminant you want TypeScript to exhaust.
if / return when there are two or three branches and you want narrowing more than a table.
That last case is most of React. The leftover is ordered booleans with no single key. Use it there. Leave it alone everywhere else.
Stay in touch
Don't miss out on new posts or project updates. Hit me up on X for updates, queries, or some good ol' tech talk.
Follow @zkmake