The "Component Switch" Pattern I Use Instead of Big Render Trees
Big if/else ladders that pick a component by a tag are unreadable by the third branch. The pattern I use instead is a tiny lookup registry. Three accordions on shipping it.
By @diegonguyen
April 9, 2026
·
Updated August 9, 2026
781 views
25
4.5 (8)
// The smallest version of the pattern. A plain object whose keys are the
// shape tags and whose values are the components, plus a switch component
// that looks up the entry and spreads the rest of the props through. That is
// it. Less than 10 lines of glue, and it scales linearly with the number of
// tags rather than nesting.
const { createElement } = (typeof React !== 'undefined' ? React : {
createElement: (type, props, ...kids) => ({ type, props: props || {}, children: kids }),
});
// Three real components. In practice they live in their own files.
function CardView(props) {
return createElement('div', { className: 'card' }, 'card[' + props.title + ']');
}
function ListView(props) {
return createElement('ul', null, ...props.items.map((it) => createElement('li', null, it)));
}
function GridView(props) {
return createElement('div', { className: 'grid' }, 'grid of ' + props.items.length + ' tiles');
}
// The registry. New view = add one line.
const registry = {
card: CardView,
list: ListView,
grid: GridView,
};
function ComponentSwitch(props) {
const { tag } = props;
const Found = registry[tag];
if (!Found) return null;
// Pass everything except `tag` through. Spread keeps the call site honest.
const rest = Object.assign({}, props);
delete rest.tag;
return createElement(Found, rest);
}
console.log(JSON.stringify(ComponentSwitch({ tag: 'card', title: 'Hello' }), null, 2));
console.log(JSON.stringify(ComponentSwitch({ tag: 'list', items: ['a', 'b', 'c'] }), null, 2));
console.log(JSON.stringify(ComponentSwitch({ tag: 'grid', items: [1, 2, 3, 4] }), null, 2));
console.log('unknown tag returns null:', ComponentSwitch({ tag: 'nope', items: [] }));Once I started writing this pattern, I stopped writing the if/else version. The registry is the entire surface area: every new view type is one line in the object, and the switch component has no behaviour beyond "look it up, render it, pass the rest of the props". Spreading the props rather than enumerating them is what keeps the switch generic; if a future view needs an extra prop, you just add it at the call site. The null return on an unknown tag is a deliberate choice: silently rendering nothing is the right behaviour during a feature flag rollout where the new tag has not landed yet, and the next accordion handles the strict variant for cases where you want a hard error instead.
// As soon as the registry has more than three or four entries, you do not
// want to import them all at app start. Swap each value for a React.lazy
// factory and wrap the switch in Suspense. The call site stays identical.
// We mock React.lazy and Suspense the same way the route-splitting snippet
// does so this runs in the playground.
const { useState } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
});
function lazy(loader) {
let status = 'pending';
let result;
let promise;
return function LazyComponent(props) {
if (status === 'resolved') return result.default(props);
if (status === 'pending') {
if (!promise) {
promise = loader().then(
(mod) => { status = 'resolved'; result = mod; },
(err) => { status = 'rejected'; result = err; }
);
}
return { __suspended: true, promise };
}
throw result;
};
}
function Suspense(opts) {
const node = typeof opts.children === 'function' ? opts.children() : opts.children;
if (node && node.__suspended) return { type: 'fallback', value: opts.fallback, await: node.promise };
return { type: 'rendered', value: node };
}
// Mock dynamic imports. In a real app these are import('./CardView') etc.
const loadCard = () => Promise.resolve({ default: (p) => 'card[' + p.title + ']' });
const loadList = () => Promise.resolve({ default: (p) => 'list of ' + p.items.length });
const loadGrid = () => Promise.resolve({ default: (p) => 'grid of ' + p.items.length });
const registry = {
card: lazy(loadCard),
list: lazy(loadList),
grid: lazy(loadGrid),
};
function LazyComponentSwitch(props) {
const Found = registry[props.tag];
if (!Found) return null;
const rest = Object.assign({}, props);
delete rest.tag;
return Suspense({
fallback: 'loading ' + props.tag + '...',
children: () => Found(rest),
});
}
const first = LazyComponentSwitch({ tag: 'list', items: ['a', 'b', 'c'] });
console.log('first render:', first.type, '->', first.value);
first.await.then(() => {
const second = LazyComponentSwitch({ tag: 'list', items: ['a', 'b', 'c'] });
console.log('second render:', second.type, '->', second.value);
});
// And a different tag the first time loads its own chunk.
const card1 = LazyComponentSwitch({ tag: 'card', title: 'Hello' });
console.log('card cold:', card1.type, '->', card1.value);
card1.await.then(() => {
const card2 = LazyComponentSwitch({ tag: 'card', title: 'Hello' });
console.log('card warm:', card2.type, '->', card2.value);
});The cost of the basic registry is that every view ships in the initial bundle. For three views that is fine; for thirty it is the wrong default. Wrapping each value in React.lazy(() => import('./Foo')) makes each view its own chunk, and the bundler only fetches the chunks the user actually triggers. The Suspense wrapper is what handles the in-flight state, and per-tag fallback strings are the easiest way to give each view a useful skeleton without writing component-specific loading UI. The trade-off is one network round-trip on first use of each tag; pair this with the preload-on-hover trick from the route-splitting snippet if the latency is visible.
// In a long-lived codebase, the silent-null behaviour eventually bites you:
// someone renames a tag and the switch silently disappears in production.
// The strict version throws on unknown tags, with a helpful message that
// names the available keys. I gate the throw behind process.env.NODE_ENV
// so prod still soft-fails to a placeholder.
const { createElement } = (typeof React !== 'undefined' ? React : {
createElement: (type, props, ...kids) => ({ type, props: props || {}, children: kids }),
});
const registry = {
card: (p) => 'card[' + p.title + ']',
list: (p) => 'list of ' + (p.items || []).length,
grid: (p) => 'grid of ' + (p.items || []).length,
};
function StrictComponentSwitch(props) {
const Found = registry[props.tag];
if (!Found) {
const known = Object.keys(registry).sort().join(', ');
const message = 'ComponentSwitch: unknown tag "' + props.tag + '". Known tags: ' + known;
// In dev: throw and let the error boundary pick it up. In prod: log and render a
// small placeholder so the rest of the page still renders.
const env = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV) || 'development';
if (env !== 'production') throw new Error(message);
if (typeof console !== 'undefined' && console.warn) console.warn(message);
return createElement('span', { 'data-tag': props.tag }, '[unknown view]');
}
const rest = Object.assign({}, props);
delete rest.tag;
return Found(rest);
}
// Happy paths still work.
console.log(StrictComponentSwitch({ tag: 'card', title: 'Hello' }));
console.log(StrictComponentSwitch({ tag: 'list', items: [1, 2, 3] }));
// Typo: try/catch so we can show what the dev-mode error looks like.
try {
StrictComponentSwitch({ tag: 'cardd', title: 'Hello' });
} catch (err) {
console.log('dev error caught:', err.message);
}
// Production behaviour: monkey-patch process.env to demonstrate the soft path.
if (typeof process !== 'undefined' && process.env) process.env.NODE_ENV = 'production';
const placeholder = StrictComponentSwitch({ tag: 'cardd', title: 'Hello' });
console.log('prod placeholder ->', JSON.stringify(placeholder));
if (typeof process !== 'undefined' && process.env) process.env.NODE_ENV = 'development';
// Discriminated-union mental model (TS): if the registry keys are a union
// type and props are a discriminated union over `tag`, the switch is
// exhaustively type-checked. We do not write that in JS, but the registry
// shape is what makes it possible to layer that on without rework.
console.log('registry keys are the closed set of valid tags:', Object.keys(registry));Soft-failing in production and hard-failing in development is the trade-off that has held up best for me. A typo in a tag prop is a bug, and the only thing worse than a missing component is one that disappears silently and stays missing for two release cycles. Throwing in dev surfaces the bug at the call site, the error boundary catches it, and the message names the valid tags so the fix is one keystroke. Production logs the same message and renders a small [unknown view] placeholder so an outage in one tag does not nuke the whole page. If you are on TypeScript, the registry shape pays an additional dividend: typing the registry as Record<Tag, ComponentType<PropsForTag>> lets the type-checker prove the switch handles every tag, which is the discriminated-union mental model the explanation in the breakdown alludes to.
