Keeping your app fast as it grows.
Vite handles code splitting automatically. Every React.lazy(() => import(...)) in your route manifest becomes its own chunk -- no configuration needed. You add a feature, Vite creates a chunk. That's it.
// This single line is all it takes. Vite splits the chunk automatically.
{ id: 'my-feature', path: '/my-feature', component: lazy(() => import('../pages/features/MyFeaturePage')) },The result:
- The main bundle stays small (~50KB: framework + router + manifest) regardless of how many features you add
- Each feature loads ~10-20KB on demand, only when the user navigates to it
- At 200 features, the initial load is the same as at 2
The two-tier registry pattern takes this further by lazy-loading feature metadata (icons, descriptions, categories) separately from the routes themselves. See Bundle Discipline for the full strategy.
For features that process input in real-time (text transforms, search, validation):
import { useDebounce } from '../hooks/useDebounce';
function SearchFeature() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
performSearch(debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}Why 300ms: Fast enough to feel responsive, slow enough to avoid unnecessary work. Adjust per feature -- search may want 150ms, expensive computations may want 500ms.
For expensive computations that depend on stable inputs:
const result = useMemo(() => {
return expensiveCalculation(input);
}, [input]);When to use:
- Complex data transformations
- Filtering/sorting large lists
- Regex operations on large text
When NOT to use:
- Simple lookups or string operations
- Values that change on every render
- Premature optimization (measure first)
For operations that take >50ms and would block the UI thread:
// worker.js
self.onmessage = (e) => {
const result = heavyComputation(e.data);
self.postMessage(result);
};
// Component
const worker = useMemo(() => new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }), []);
useEffect(() => {
worker.postMessage(inputData);
worker.onmessage = (e) => setResult(e.data);
}, [inputData, worker]);Use cases: Image processing, large file parsing, complex math, data compression.
When creating URLs for file previews, always clean up to prevent memory leaks:
import { useObjectURL } from '../hooks/useObjectURL';
function ImagePreview({ file }) {
const [url, setBlob] = useObjectURL();
useEffect(() => {
setBlob(file);
}, [file, setBlob]);
return url ? <img src={url} alt="Preview" /> : null;
}The useObjectURL hook automatically revokes old URLs when creating new ones and on unmount.
For canvas-based features, use the syncCanvas utility to handle high-DPI displays:
import { syncCanvas } from '../utils/canvasHiDPI';
useEffect(() => {
syncCanvas(canvasRef.current, width, height);
const ctx = canvasRef.current.getContext('2d');
// Draw at logical coordinates -- the utility handles scaling
}, [width, height]);Without this, canvas content appears blurry on Retina/high-DPI displays.
// Stable callbacks with useCallback
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
// Stable objects with useMemo
const config = useMemo(() => ({
format: 'json',
indent: 2,
}), []);For components that use large libraries (Three.js, CodeMirror, etc.):
const HeavyEditor = lazy(() => import('./components/HeavyEditor'));
// Only loads when isEditing becomes true
{isEditing && (
<Suspense fallback={<LoadingSpinner />}>
<HeavyEditor />
</Suspense>
)}