React Conditional Rendering With Type Safety and Exhaustive Checking

Lloyd Atkinson
Utilising a union and record type for type safe and exhaustive conditional rendering
Utilising a union and record type for type safe and exhaustive conditional rendering

There are plenty of articles showing the typical if/else, switch, and ?: ternary approaches along with the usual warnings or considerations when using them. For example, it’s common knowledge that many nested ternaries can harm code readability, so often a switch might be used in it’s place, or multiple components are rendered where each one conditionally renders itself. This topic is surprisingly a common source of heated debate in the React community.

While switch in the context of React is an improvement, unfortunately, a switch statement is not exhaustive like pattern matching in a functional language. This means it’s easy to forget to update a switch.

This pattern isn’t specific to just React. I considered demonstrating the pattern for multiple frameworks including Vue but decided against it.

There is an alternative approach that leads to more readable code while also adding extra type safety. I hesitate to call this a “new” or less known conditional rendering method, but after implementing it in a project and then searching for it online I have not seen this pattern described in this specific way using TypeScript.

Outline

For no specific reason (besides fruit being delicious), I decided to have a component that renders a fact about the chosen fruit. Conditionally rendering based on several states is common for status labels, forms, stepper components, wizards, and the like. To reiterate, there is no if/else if chain, switch, or nested ternaries in the below component!

Instead, a more maintainable and compact solution is used. It is essentially a lookup table using an object - but with added type-safety, which allows TypeScript to ensure all states are included.

Apples ripen up to 10 times faster at room temperature than if they are refrigerated.🍎🍏

Implementation

To begin with, I created a union type that contains all possible states. The union isn’t an absolute necessity for this general pattern, but it’s how I can enforce type-safety and exhaustive pattern matching. A union, as those familiar with TypeScript will know, can be a union of disparate types - not just strings. For this particular example, strings work well. They will be passed as props to the React component.

export type Fruit = 'apple' | 'kiwi' | 'cherry' | 'grape';

We now have TypeScript feedback during development.

TypeScript Feedback

Next, I created a lookup table object. This object is a map of the union type to a string containing the fruit facts. The not type-safe way you often see in typical code would be a plain object where the key is a string, and the value is a string. As I’ve already explained, we would not get the TypeScript feedback, and type safety demonstrated previously.

Stay up to date

Subscribe to my newsletter to stay up to date on my articles and projects

The best approach for a type-safe object is the Record utility type. I wrote an article on record types previously. To summarise, a record type makes type-safe objects with a known set of keys.

Record<Keys, Type> - Constructs an object type whose property keys are Keys and whose property values are Type. This utility can be used to map the properties of a type to another type.

const fact: Record<Fruit, string> = {
apple: 'Apples ripen up to 10 times faster at room temperature than if they are refrigerated.',
kiwi: 'Kiwi fruits contain about as much potassium as Bananas and a high amount of Vitamin C, more than Oranges.',
cherry: 'Cherries are rich in vitamin A, B and C, mineral salts and dietary minerals (zinc, copper, manganese, cobalt).',
grape: 'Grapes come in many colors, including green, red, black, yellow, pink, and purple. "White" grapes are actually green.',
};

The next step is to apply the same pattern to the conditional rendering of elements or components. For this, instead of a record mapping the fruit union to a string, we will map it to a React node. In our case, we have a specific component for each fruit emoji.

import { ReactNode } from 'react';
const Apple = () => <span>🍎🍏</span>;
const Kiwi = () => <span>🥝</span>;
const Cherry = () => <span>🍒</span>;
const Grape = () => <span>🍇</span>;
const icon: Record<Fruit, ReactNode> = {
apple: <Apple />,
kiwi: <Kiwi />,
cherry: <Cherry />,
grape: <Grape />,
};

It’s worth reiterating what we have so far because this is a powerful pattern:

  • A union type of all possible states
  • Record types mapping from the state to strings and React nodes
  • Exhaustive pattern matching, which ensures all states have to be included to be valid code

TypeScript Feedback

Final code

The entire component is below. The correct icon and fact will be rendered depending on which fruit is passed as a prop. This component demonstrates how to conditionally render strings and elements based on a type-safe lookup table. TypeScript will ensure all states are included, and the correct type is returned. The pattern significantly improves code readability and maintainability.

It can be taken further, too, where the union type contains objects itself instead of strings. The possibilities are numerous. This is not going to be how all conditional rendering is done, but it is a very nice pattern. A traditional switch will not tell you if you’ve forgotten to include a state, but this pattern will.

const Apple = () => <span>🍎🍏</span>;
const Kiwi = () => <span>🥝</span>;
const Cherry = () => <span>🍒</span>;
const Grape = () => <span>🍇</span>;
export type Fruit = 'apple' | 'kiwi' | 'cherry' | 'grape';
export const ConditionalFruitFacts = ({ fruit }: { fruit: Fruit }) => {
const fact: Record<Fruit, string> = {
apple: 'Apples ripen up to 10 times faster at room temperature than if they are refrigerated.',
kiwi: 'Kiwi fruits contain about as much potassium as Bananas and a high amount of Vitamin C, more than Oranges.',
cherry: 'Cherries are rich in vitamin A, B and C, mineral salts and dietary minerals (zinc, copper, manganese, cobalt).',
grape: 'Grapes come in many colors, including green, red, black, yellow, pink, and purple. "White" grapes are actually green.',
};
const icon: Record<Fruit, ReactNode> = {
apple: <Apple />,
kiwi: <Kiwi />,
cherry: <Cherry />,
grape: <Grape />,
};
return (
<div className="inline-block">
<span className="flex flex-col text-center">
{ icon[fruit] }
{ fact[fruit] }
</span>
</div>
);
};

Usage and testing

TypeScript Feedback

Using and unit testing the component is just like any other React component. This example unit test uses react-testing-library. Having a finite set of possible states means we can use a parameterised test to test all possible input states.

import { render, screen } from '@testing-library/react';
import { ConditionalFruitFacts, Fruit } from './ConditionalFruitFacts';
describe('Example unit test', () => {
it.each<Fruit, string>([
['apple', '🍎🍏'],
['kiwi', '🥝'],
['cherry', '🍒'],
['grape', '🍇'],
])(`should render the correct icon for %s`, (fruit, icon) => {
render(<ConditionalFruitFacts fruit={fruit} />);
expect(screen.getByText(icon)).toBe(true);
});
});

Conclusion

This pattern is a way to make it type-safe and ensure all states are covered. I hope you found it useful. I have written some other articles on React and TypeScript if you would like to read more. I have a newsletter too!

Share:

Spotted a typo or want to leave a comment? Send feedback

Stay up to date

Subscribe to my newsletter to stay up to date on my articles and projects

© Lloyd Atkinson 2024 ✌

I'm available for work 💡