Patterns that emerged at 100+ features. None of these are needed at 10 features. All of them become necessary at 100+.
The template gives you a foundation. This doc covers what you'll build on top of it as you scale.
The problem: Every feature with configurable behavior (sort order, display mode, decimal precision) needs persistent settings. Without standardization, each feature manually constructs a localStorage key and re-implements the merge pattern.
The solution: A thin hook over useLocalStorage with auto-namespacing.
const [settings, updateSetting, resetSettings] = useToolSettings('unit-converter', {
decimalMode: false,
showAdvanced: false,
});
// Partial update -- only changes the key you specify
updateSetting('decimalMode', true);
// Full reset to defaults
resetSettings();Internally, useToolSettings stores everything under {app}:{featureId}:settings. The updateSetting function does a shallow merge, so updating one key doesn't clobber the others.
Why extract it: At 30+ features with settings, the alternative is 30 hand-rolled useLocalStorage calls with 30 hand-rolled merge functions. This hook makes the right pattern effortless.
The problem: Many features show a "recent history" panel (password generator results, unit conversions, color picks). Each one needs timestamped entries, a cap to prevent unbounded growth, and individual removal.
The solution: A standardized hook for history management.
const [history, addEntry, clearHistory, removeEntry] = useToolHistory('password-generator', 50);
// Add an entry -- auto-stamped with id (Date.now) and ISO timestamp
addEntry({ password: 'xK9#mP2$', strength: 'strong' });
// Remove a single entry by its auto-assigned id
removeEntry(1709234567890);
// Clear everything
clearHistory();History is newest-first, capped at maxItems (default 50). Entries older than the cap are silently dropped on the next addEntry.
Composable with HistoryPanel: The HistoryPanel shared component renders history entries with clear and per-item removal. Wire it up with one line:
<HistoryPanel items={history} renderItem={(entry) => <span>{entry.password}</span>} onClear={clearHistory} />The problem: At 178 features, each needs a unique page title, meta description, and FAQ section for search engines. Embedding this content in each feature page would scatter SEO copy across 178 files, making audits and updates painful.
The solution: Centralize all SEO content in a single data file.
// src/data/seoData.js
export const seoData = {
'unit-converter': {
seoTitle: 'Online Unit Converter',
seoDescription: 'Convert between metric, imperial, and scientific units instantly.',
faqs: [
{ q: 'How accurate are the conversions?', a: 'All conversions use IEEE 754...' },
{ q: 'Does this work offline?', a: 'Yes, all calculations run client-side...' },
],
},
// ... one entry per feature
};Consumption pipeline:
-
Runtime:
FeatureLayoutreadsseoData[featureId]and renders the H1, description paragraph, and FAQ accordion. It also feeds<Helmet>for<title>and<meta name="description">. -
Build-time: A Vite plugin (
inject-meta.mjs) bakes<title>and<meta>into static HTML chunks so crawlers see correct metadata even before JavaScript executes.
Why centralize: Updating SEO copy for all 178 features is a single-file operation. Auditing for missing or stale metadata is a single scan. Adding a new feature's SEO data is one object entry, not a scattered edit across component files.
The problem: Game-type features (chess, blackjack, dice games, maze) share hard layout problems: aspect-ratio preservation, fullscreen mode, canvas feedback-loop prevention, and responsive touch controls. Solving these in every game page leads to duplicated, fragile code.
The solution: A shared layout wrapper that handles the hardest rendering concerns.
<GameShell
aspectRatio={16/9}
isFullscreen={isFs}
onToggleFs={() => setFs(!isFs)}
canvas={<canvas ref={canvasRef} />}
stats={[{ label: 'Score', value: score }, { label: 'Level', value: level }]}
controls={<TouchDPad onMove={handleMove} />}
actions={<Button onClick={newGame}>New Game</Button>}
footer="Arrow keys to move"
/>-
Canvas mode (when
aspectRatiois provided): Uses CSS container queries to compute the largest rectangle that fits the viewport while preserving the exact ratio. The canvas is absolutely positioned inside it to prevent resize feedback loops. -
HTML mode (when
aspectRatiois omitted): Content fills via flexbox. Suitable for card games and board games that use DOM elements instead of canvas.
When toggling fullscreen, the entire component tree switches from normal layout to fixed inset-0 z-50 -- but the canvas element is never unmounted. This preserves refs, event listeners, and requestAnimationFrame loops. Without this guarantee, every fullscreen toggle would tear down and restart the game's animation loop.
| Prop | What it renders | Behavior in fullscreen |
|---|---|---|
canvas |
The game area (canvas or HTML) | Scales to fill |
stats |
StatCard array | Compact chip mode |
sidePanel |
Adjacent panel (e.g., "next piece" preview) | Preserved |
controls |
Touch controls | Visible (hidden in normal mode on desktop) |
actions |
Game buttons | Preserved |
footer |
Instructions text | Hidden |
The template ships 2 contexts (Theme + Toast). At 178 features, Overtooled uses 6. See State Management for the full breakdown and the decision tree for when a new context is justified.
The short version: a context is justified when state genuinely needs to cross React tree branches that share no common parent below App. If the state is used by siblings in separate layout zones (Navbar + Home page, Sidebar + Content area), that's a context. If the state flows from parent to child, use props.