The Five Tiny React Patterns I Type Without Thinking
Cheat sheet of the small React tricks I keep typing. Inline-style composition, args-to-handlers, SVG-as-component, and a JSON debug helper that has saved me three Saturdays.
By @ryanjoshi
February 28, 2026
·
Updated May 18, 2026
482 views
8
4.3 (12)
// Combine multiple style objects with object spread, and pass everything else
// through to the underlying DOM node so callers can add data-* / aria-*
// without adding another prop. The gotcha at the bottom: React DOM warns
// (and strips) unknown attributes when you spread arbitrary props onto a
// host element, so the props need to be either DOM-valid or filtered out.
const { createElement } = (typeof React !== 'undefined' ? React : {
createElement: (type, props, ...kids) => ({ type, props: props || {}, children: kids }),
});
const baseStyle = { padding: '8px 12px', borderRadius: 4, fontWeight: 500 };
const variantStyle = {
primary: { background: '#2b6cb0', color: 'white' },
danger: { background: '#c53030', color: 'white' },
ghost: { background: 'transparent', color: '#2b6cb0' },
};
const stateStyle = {
disabled: { opacity: 0.5, cursor: 'not-allowed' },
};
function Button(props) {
const { variant, disabled, style, children, ...rest } = props;
// Order matters: per-call `style` wins over the variant, which wins over base.
const composed = Object.assign(
{},
baseStyle,
variantStyle[variant] || variantStyle.primary,
disabled ? stateStyle.disabled : null,
style || null
);
return createElement('button', Object.assign({ style: composed, disabled }, rest), children);
}
console.log(JSON.stringify(Button({
variant: 'primary', children: 'Save', 'aria-label': 'save form', 'data-testid': 'save-btn',
}), null, 2));
console.log('per-call style override beats variant:');
const overridden = Button({ variant: 'danger', style: { background: 'rebeccapurple' }, children: 'Hi' });
console.log('background:', overridden.props.style.background, '(per-call wins)');
// The gotcha: spreading non-DOM props onto a host element. React would warn
// about `loadingState` here, but we have stripped it out via destructuring
// and only rest-spread `aria-*` / `data-*` / event handlers.
const { loadingState, ...domSafe } = { loadingState: 'idle', 'aria-busy': 'false', onClick: () => {} };
console.log('non-DOM prop separated:', loadingState);
console.log('DOM-safe rest:', Object.keys(domSafe).join(', '));The pattern is older than hooks but I still type it weekly. Compose with Object.assign({}, base, variant, state, override) rather than re-implementing CSS specificity by hand; the spread order is the cascade, and a per-call style always wins. The destructure-then-spread idiom is what makes the component a good citizen on the DOM: pulling out the props that belong to the component logic (variant, disabled, loadingState) and rest-spreading the rest preserves all the call-site aria-*, data-*, and event handlers without you maintaining an enumerated allowlist. The constructor pattern from the legacy snippet collection is the same idea in a class context, with this.props taking the place of the destructure.
// Two ways to forward an id (or any closed-over value) to a click handler.
// Inline arrow is the easiest write but allocates per render. .bind allocates
// too. The cheapest version is data attributes plus a single delegated handler.
// In practice I pick by row count: <100 inline arrow, >1k delegate.
const { useCallback } = (typeof React !== 'undefined' ? React : {
useCallback: (f) => f,
});
function handleSelect(id) { console.log('selected', id); }
// Style 1: inline arrow. Simplest, allocates a new function per row per render.
const rows = [{ id: 1 }, { id: 2 }, { id: 3 }];
function renderRowsInline() {
return rows.map((r) => ({
type: 'tr',
props: { onClick: () => handleSelect(r.id) },
children: [r.id],
}));
}
console.log('inline-arrow rendered rows:', renderRowsInline().length);
// Style 2: .bind. Same cost, less idiomatic.
function renderRowsBind() {
return rows.map((r) => ({
type: 'tr',
props: { onClick: handleSelect.bind(null, r.id) },
children: [r.id],
}));
}
console.log('bound rows:', renderRowsBind().length);
// Style 3: data attributes plus one delegated handler at the parent. Zero
// per-row allocations no matter how many rows there are.
function onTableClick(event) {
// event.target.dataset.id in real DOM. We mock the event shape below.
const id = event.target && event.target.dataset && event.target.dataset.id;
if (id != null) handleSelect(Number(id));
}
function renderRowsDelegated() {
return {
type: 'tbody',
props: { onClick: onTableClick },
children: rows.map((r) => ({ type: 'tr', props: { 'data-id': String(r.id) }, children: [r.id] })),
};
}
const tree = renderRowsDelegated();
console.log('delegated handler is shared:', typeof tree.props.onClick, '->', tree.children.length, 'rows');
// HTML-vs-React event quirks worth remembering:
// * camelCase: onClick (React) vs onclick (HTML attribute), but addEventListener('click')
// in vanilla.
// * `event.preventDefault()` is the only way to cancel default; returning false from a
// React handler does NOT cancel.
// * Synthetic events are pooled in React 16, not in 17+. e.persist() was the legacy escape
// hatch; you almost never need it now.
// * onChange in React fires on every keystroke (not on blur, like the HTML attribute).
console.log('quirks documented in code-comments above; the most-asked is preventDefault.');If you have a handful of rows, the inline arrow is fine and the rebuild cost is invisible. The pattern that matters is the third one: a single delegated handler on the parent reads event.target.dataset.id, calls the real callback, and the per-row functions disappear from the render. I reach for it whenever a list grows past about 1000 rows or whenever I am chasing a memo that is not hitting because every row's onClick is fresh on every render. The HTML-vs-React quirks at the bottom are the ones I get asked about most often: returning false from a handler does not cancel the default like it does in inline HTML attributes, and onChange is keystroke-level in React even though the native event fires on blur.
// CRA exposes the import { ReactComponent as Logo } from './logo.svg' magic
// that turns an .svg file into a real React component. Vite has a similar
// plugin (vite-plugin-svgr). For everything else, I write the SVG inline as a
// React function component once and re-use it. Both styles below.
const { createElement } = (typeof React !== 'undefined' ? React : {
createElement: (type, props, ...kids) => ({ type, props: props || {}, children: kids }),
});
// Style 1: the CRA / svgr import. Writing it as a comment because the actual
// import only resolves under a real bundler with the svg loader configured.
// import { ReactComponent as Logo } from './logo.svg';
// <Logo width={32} height={32} fill="currentColor" />
//
// Under the hood, svgr generates roughly this. The point of the pattern is
// that the SVG file stays the source of truth and designers can re-export it
// without anyone re-typing the markup.
function Logo(props) {
return createElement(
'svg',
Object.assign({ viewBox: '0 0 24 24', xmlns: 'http://www.w3.org/2000/svg' }, props),
createElement('path', { d: 'M12 2 L22 22 L2 22 Z', fill: 'currentColor' })
);
}
const rendered = Logo({ width: 32, height: 32, 'aria-hidden': 'true' });
console.log('logo node:', JSON.stringify({ type: rendered.type, props: rendered.props, kids: rendered.children.length }));
// Style 2: the inline-SVG fallback for projects without a loader. Same shape,
// just hand-written. I wrap any non-trivial SVG in a component so the markup
// is reusable and the props (size, color) are explicit.
function CheckIcon(props) {
const { size, color, ...rest } = props;
return createElement(
'svg',
Object.assign({
viewBox: '0 0 24 24',
width: size || 16,
height: size || 16,
fill: 'none',
stroke: color || 'currentColor',
'stroke-width': 2,
'aria-hidden': 'true',
}, rest),
createElement('path', { d: 'M5 13 L9 17 L19 7' })
);
}
const icon = CheckIcon({ size: 24, color: '#2b6cb0' });
console.log('check icon size:', icon.props.width, '| stroke:', icon.props.stroke);
// Note on aria-hidden: decorative SVG should always carry aria-hidden="true"
// so screen readers do not announce a meaningless path. SVG that conveys
// information should have role="img" and an <title> child instead.
console.log('aria-hidden present:', icon.props['aria-hidden']);The svgr-style import is genuinely the nicest developer experience in the React ecosystem because the .svg file remains the source of truth and the React component is generated. If you do not have it, the inline-SVG component is the right fallback: write the markup once, expose size and color as props, and forward everything else with rest-spread. The aria-hidden="true" default is the accessibility line I do not skip; almost every icon in a UI is decorative, and announcing every path to a screen reader is what makes SVG-heavy interfaces unusable for keyboard-and-screen-reader users. Use role="img" plus a <title> child only when the icon carries semantic meaning that nothing else expresses.
// The component I drop into any view to see what state actually looks like.
// Renders <pre>{JSON.stringify(value, null, 2)}</pre>, hidden behind a tiny
// toggle so it never ships visible. Strips known-noisy keys (functions, refs)
// so the output is readable. Runs nowhere in production via NODE_ENV check.
const { createElement, useState } = (typeof React !== 'undefined' ? React : {
createElement: (type, props, ...kids) => ({ type, props: props || {}, children: kids }),
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
});
function safeReplacer(_key, value) {
if (typeof value === 'function') return '[fn ' + (value.name || 'anonymous') + ']';
if (value && typeof value === 'object' && value.nodeType) return '[DOMNode]';
if (value instanceof Error) return { name: value.name, message: value.message };
return value;
}
function DebugJSON(props) {
const env = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV) || 'development';
if (env === 'production') return null;
const [open, setOpen] = useState(props.defaultOpen != null ? props.defaultOpen : false);
const label = props.label || 'debug';
const json = JSON.stringify(props.value, safeReplacer, 2);
return createElement(
'div',
{ 'data-debug': label, style: { fontSize: 12, fontFamily: 'monospace', opacity: 0.7 } },
createElement('button', {
onClick: () => setOpen(!open),
style: { padding: '2px 6px', border: '1px solid #ccc', background: 'transparent', borderRadius: 3 },
}, (open ? '[-] ' : '[+] ') + label),
open ? createElement('pre', { style: { background: '#f6f8fa', padding: 8, borderRadius: 4 } }, json) : null
);
}
const state = {
user: { id: 1, name: 'Ada' },
handler: function onSave() {},
error: new Error('would have crashed here'),
};
const hidden = DebugJSON({ value: state, label: 'app state' });
console.log('default state label:', hidden.children[0].children[0]);
const opened = DebugJSON({ value: state, label: 'app state', defaultOpen: true });
console.log('opened pre child preview:');
console.log(opened.children[1].children[0]);
// In production the component returns null entirely.
if (typeof process !== 'undefined' && process.env) process.env.NODE_ENV = 'production';
const inProd = DebugJSON({ value: state, label: 'app state', defaultOpen: true });
console.log('in production:', inProd);
if (typeof process !== 'undefined' && process.env) process.env.NODE_ENV = 'development';I have written this component three times, finally pasted it into my dotfiles, and stopped writing it. The shape is <DebugJSON value={state} label="cart" />, hidden behind a one-character toggle so it is unobtrusive when you forget to remove it. The safeReplacer is the bit that earned its keep: a raw JSON.stringify of any React state with handlers in it throws on circular references and prints {} for functions, both of which made the original version useless. The production-mode short-circuit is the pragmatic part: the component returns null when NODE_ENV === 'production', so a stray instance left in a PR does not actually ship visible to users. Three Saturdays is not an exaggeration; one debug helper saves the time of writing the equivalent ad-hoc console.log chains every time you debug a stateful component.
