(Lazy) Components Maps

Cleaner conditional rendering in React by matching values using component maps, optionally, lazy-load components to make a “lazy component map”

Published · Last updated

Conditionally rendering elements is fundamental to every React app, it’s the driving force behind interactivity. React doesn’t have its own syntax for conditionals like some other frameworks, but relies on just JavaScript conditionals. There are multiple ways to do conditionals in JS, so there are multiple ways to do it in React. The React docs provides a good overview of approaches in Conditional Rendering.

If/elses, early returns, switch statements, logical and ternary operators, they all work! In the end it mostly comes down to personal preference.

But when you’re matching a value against a known set of values — which is very common in conditional rendering — if/else and ternaries get a little verbose. The condition is repeated for each possible value.

For example, say you’re conditionally rendering a time picker component based on a period of time:

if/else
Ternary
Logical

if (type === "day") {
return <DayPicker />;
} else if (type === "week") {
return <WeekPicker />;
} else if (type === "month") {
return <MonthPicker />;
} else if (type === "year") {
return <YearPicker />;
} else {
return "Unknown type!";
}

Pattern Matching Proposal

There’s a TC39 proposal to add proper pattern matching to JS to address the shortcomings of the switch statement . It will probably take a while to land (if ever). In the meantime, you can abuse a switch statement for pseudo-pattern matching. Set the switch expression to true and make each case an expression.


switch (true) {
case (energy > 100): {
return <HighEnergy />;
}
case (energy < 100): {
return <LowEnergy />;
}
}

See this post Pattern Matching in JavaScript for more on this.

Or check out this clever TypeScript library for pattern matching TS-Pattern.

Tips for Avoiding Repeating Conditions

You can use multiple conditions for one switch clause, which I think looks nicer than a stack of ||’s in the if condition.


case "minute":
case "hour":
case "day": {
return <DayPicker />;
}

Another way to prevent a ‘condition stack’ is to turn it around by putting the values an array and checking it with .includes().


[
"minute",
"hour",
"day"
].includes(type)

Or use a Set with the .has() method.

The great eslint-plugin-unicorn contains a prefer-switch rule: A switch statement is easier to read than multiple if statements with simple equality comparisons.

Here, a switch statement provides slightly cleaner syntax. It matches an expressions value, the type, against a set of values — here day, week, month, and year. If type matches any of these, the corresponding picker component is rendered.


switch (type) {
case "day": {
return <DayPicker />;
}
case "week": {
return <WeekPicker />;
}
case "month": {
return <MonthPicker />;
}
case "year": {
return <YearPicker />;
}
default: {
throw new Error("Unknown type!");
}
}

Not bad but I think there’s a better way.

This switch statement is really quite similar to a key/value map. It maps keys (strings) to values (React elements/components). Let’s try that instead.

Every plain ol’ JavaScript object is a simple key/value map.


// A component map
const components = {
day: DayPicker,
week: WeekPicker,
month: MonthPicker,
year: YearPicker,
};

You can use a Map object too, it can even have non-string keys.


const components = new Map([
["day", DayPicker],
["week", WeekPicker],
["month", MonthPicker],
["year", YearPicker],
]);

Component maps have all the values and components in one place, concise and comprehensible. That makes them more legible than if/else or ternaries in my opinion. I think maps are an often overlooked tool for conditionals in general.

Now combine this with the fact that JSX can be ‘dynamic’; a component type can be determined at runtime.

Object
Map

const SelectedPicker = components[type];
// in component return
return (
<SelectedPicker
selectedPeriod={selectedPeriod}
setSelectedPeriod={setSelectedPeriod}
/>);

Note that the variable name must be capitalized in order to be used as JSX. It’s how React can tell it’s a component instead of a DOM tag (or more specifically, a host element) .

Component maps are limited in some ways, they can’t always be used for conditional rendering.

If you can’t be sure the key is actually in the map, you want to prefer a switch statement (or any other conditional). A default case or else block can handle missing cases explicitly, component maps can’t do that.

If the value is not in the component map, the component type will be undefined and you’ll get this error message when you try to render it:


Element type is invalid: expected a string (for built-in components) or a
class/function (for composite components) but got: undefined.

TypeScript Tip

With TypeScript you’ll be warned when you try a key that’s not in the map. Use both the keyof and typeof operators on the component map object to get the string literal union of its keys.

It’s possible to make it fail silently by assigning a default HTML tag string using the nullish coalescing operator.

Object
Map

const SelectedPicker = components[type] ?? "div";

But I don’t think this is a good solution. It won’t display any warnings, it’ll just add a useless empty HTML element to the page. You could make the fallback an obscure HTML tag and add an error message to it using CSS selectors and pseudo-elements, but all of this is terribly, terribly hacky. Moreover the props are passed to the HTML tag as is, that’s not good either.

The components in the example above conveniently take no props! This is not very realistic. In a real app, date picker components would receive props, like a state value and a state-setter function.

With conditionals you can pass different props to the different component in each case.


switch (type) {
case "day": {
return (
<DayPicker currentDay={today} />
);
}
case "week": {
return (
<WeekPicker currentWeek={week} />
);
}
}

Whereas component maps quietly assume that the component takes the same props. Though in this example, it wouldn’t be unreasonable for each picker to require the same props.

Some of these components might never be rendered at all, yet we still load the code for them! Hardly efficient, this is where code splitting comes in.

To code split the components, make each component a module: move each of them to a separate file and export default each. Import them with a dynamic import() using React.lazy().


const DayPicker = React.lazy(() => import("./DayPicker"));

The code for this component will be loaded automatically the first time it is first rendered .

Lazy-loading requires us to do two things in the React component tree. First, the lazy-loaded component has to be rendered inside a <Suspense> boundary . It will display a fallback, usually a loading indicator like a spinner, while it’s loading. It can technically be added anywhere above. It doesn’t need to wrapped directly around it, though for showing a fallback component it’s better to keep it close .

The best practice is to place <Suspense> where you want to see a loading indicator, but to use lazy() wherever you want to do code splitting.

Second, make sure an <ErrorBoundary/> is placed anywhere above to handle when the module fails to load, and to attempt a retry. Like the Suspense boundary, it’s better to keep it close.

Here’s how lazy-loading can be combined with a component map.

Object
Map

const components = {
day: lazy(() => import("./day-picker")),
week: lazy(() => import("./week-picker")),
month: lazy(() => import("./month-picker")),
year: lazy(() => import("./year-picker")),
};
// in component
const SelectedPicker = components[type];
return (
<ErrorBoundary FallbackComponent={ErrorFallback} onError={handleError}>
<Suspense fallback={<Spinner />}>
<SelectedPicker />
</Suspense>
<ErrorBoundary>
)

Lazy-loading is (probably) only worth it when there are a lot of components in the map, and/or if the component are heavy, meaning they render a large subtree or make use of heavy dependencies. It’s not necessary to make every component map lazy.

Also note that lazy-loading is not unique to component maps, any conditionally rendered component can be code split with React.lazy.

Rendering a lazy-loaded component for the first time will trigger the Suspense fallback and result in a split second of UI jank while the other component is being loaded. If you are on React 18, you can avoid this ‘fallback flash’ by wrapping the setter in startTransition to mark it as a ‘non-urgent update’ . That way React will keep the old UI around until the new UI is ready .


onChange={(e) => startTransition(() => setType(e.target.value))}

Use Case: SVG Icon Map

I saw this tweet by Cory House that demonstrates a great use case of a lazy component map, an SVG icon component.

Agree, nice one Cory! Although I like my name for it slightly better. This is nice if you want TypeScript to show all available icon names in one place. Here, using an object to select the component is much more compact than a switch statement.

This is nice if you import a folder of SVG files with SVGr for example. Lazy–loading ensures we only pay for the icons we actually use. Another thing that makes this a great fit for component maps is that all components in the map take the same props; SVG element props.

So, wrapping up: a component map is useful when

  1. You prefer its compactness over other other forms of conditionals.
  2. The components in it all take the same or no props.

You can make it a ‘lazy’ component map when you the components in it are worth code-splitting. You can avoid a ‘fallback flash’ by wrapping the update in startTransition.

As usual, I’ve made a CodeSandbox to go along with the post. You can see the effect of startTransition in the UI, so be sure to check it out.

  1. Ternary operators become hard to read after the first level. Even if they seem to save space at the time, it’s better to be explicit and obvious in your intentions.
    Tao of React: Avoid Nested Ternaries - Software Design, Architecture & Best Practices | Alex Kondov
  2. A ternary is a fine way to switch between two pieces of JSX. Once you go beyond 2 items, the lack of an else if () turns your logic into a bloody mess real quick. Any extra conditions inside a ternary branch, be it a nested ternary or a simple &&, are a red flag.
    Good advice on JSX conditionals - Don’t get stuck in ternaries
  3. There are many ways to match values in the language, but there are no ways to match patterns beyond regular expressions for strings. switch is severely limited: it may not appear in expression position; an explicit break is required in each case to avoid accidental fallthrough; scoping is ambiguous (block-scoped variables inside one case are available in the scope of the others, unless curly braces are used); the only comparison it can do is ===; etc.
    TC 39 Pattern Matching Proposal
  4. When an element type starts with a lowercase letter, it refers to a built-in component like <div> or <span> and results in a string ’div’ or ’span’ passed to React.createElement. Types that start with a capital letter like <Foo /> compile to React.createElement(Foo) and correspond to a component defined or imported in your JavaScript file. We recommend naming components with a capital letter. If you do have a component that starts with a lowercase letter, assign it to a capitalized variable before using it in JSX.
    JSX In Depth - User-Defined Components Must Be Capitalized
  5. The React.lazy function lets you render a dynamic import as a regular component. React.lazy takes a function that must call a dynamic import(). This will automatically load the bundle containing [that component] when [it’s] first rendered. This must return a Promise which resolves to a module with a default export containing a React component. The lazy component should then be rendered inside a Suspense component, which allows us to show some fallback content (such as a loading indicator) while we’re waiting for the lazy component to load. Note that lazy components can be deep inside the Suspense tree — it doesn’t have to wrap every one of them. The best practice is to place <Suspense> where you want to see a loading indicator, but to use lazy() wherever you want to do code splitting.
    React Docs: Codesplitting - React.lazy
  6. React.lazy currently only supports default exports. If the module you want to import uses named exports, you can create an intermediate module that reexports it as the default. This ensures that tree shaking keeps working and that you don’t pull in unused components.
    React docs Code Splitting: Named Exports
  7. The lazy API is powered by Suspense. When you render a component imported with lazy, it will suspend if it hasn’t loaded yet. This allows you to display a loading indicator while your component’s code is loading.
    React Docs: Suspense — Lazy-loading components with Suspense
  8. When a component suspends, it activates the fallback of only the nearest parent Suspense boundary. This means you can nest multiple Suspense boundaries to create a loading sequence. Each Suspense boundary’s fallback will be filled in as the next level of content becomes available.
    React Docs: Suspense — Revealing nested content as it loads
  9. Any component may suspend as a result of rendering, even components that were already shown to the user. In order for screen content to always be consistent, if an already shown component suspends, React has to hide its tree up to the closest <Suspense> boundary. However, from the user’s perspective, this can be disorienting. [...] However, sometimes this user experience is not desirable. In particular, it is sometimes better to show the “old” UI while the new UI is being prepared. You can use the new startTransition API to make React do this.
    React docs Code Splitting: React.lazy - Avoiding fallbacks
  10. React.startTransition lets you mark updates inside the provided callback as transitions. This method is designed to be used when React.useTransition is not available. Updates in a transition yield to more urgent updates such as clicks. Updates in a transition will not show a fallback for re-suspended content, allowing the user to continue interacting while rendering the update.
    React API: useTransition