References

JavaScript & DOM Performance Patterns

Passive event listeners, SVG optimization, JSX hoisting, DOM batching, and memoized selectors

Core Patterns

  • Passive Event Listeners for Scroll/Touch
  • SVG Optimization
  • JSX Constant Hoisting
  • DOM Batching
  • Memoized Selectors and Computations

Passive Event Listeners for Scroll/Touch

Non-passive event listeners on scroll/touch block the main thread and prevent the browser from optimizing scrolling. Add { passive: true } when preventDefault() is not called.

// ❌ WRONG: Blocks scroll optimization — browser must wait to see if preventDefault is called
useEffect(() => {
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

// ✅ CORRECT: passive: true tells browser this handler never calls preventDefault
useEffect(() => {
  window.addEventListener('scroll', handleScroll, { passive: true });
  window.addEventListener('touchstart', handleTouch, { passive: true });
  return () => {
    window.removeEventListener('scroll', handleScroll);
    window.removeEventListener('touchstart', handleTouch);
  };
}, []);

Use passive: false only when you explicitly need event.preventDefault() (e.g., preventing pull-to-refresh on a custom scroll container). Lighthouse flags non-passive scroll listeners as a performance issue.


SVG Optimization

Inline SVGs as React components avoid extra HTTP requests and enable CSS/prop-based styling, but unoptimized SVGs add unnecessary bytes.

// ❌ WRONG: Raw SVG with editor bloat — metadata, redundant attributes, fixed colors
export function Icon() {
  return (
    <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
         viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2"
         stroke-linecap="round" stroke-linejoin="round"
         data-v-abc123 xml:space="preserve">
      {/* ... */}
    </svg>
  );
}

// ✅ CORRECT: Cleaned SVG accepting currentColor and size props
interface IconProps {
  size?: number;
  className?: string;
}

export function CheckIcon({ size = 24, className }: IconProps) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
    >
      <polyline points="20 6 9 11 4 16" />
    </svg>
  );
}

Use currentColor for stroke/fill so the icon inherits CSS color. Run SVGs through SVGO or use SVGR CLI to strip editor metadata automatically.


JSX Constant Hoisting

JSX elements and objects defined inside a component body are recreated on every render. Move static values outside the component.

// ❌ WRONG: options array recreated on every render — breaks React.memo on child
function Select({ value, onChange }) {
  const options = [
    { value: 'asc', label: 'Ascending' },
    { value: 'desc', label: 'Descending' },
  ];
  return <Dropdown options={options} value={value} onChange={onChange} />;
}

// ✅ CORRECT: Hoisted outside component — same reference across renders
const SORT_OPTIONS = [
  { value: 'asc', label: 'Ascending' },
  { value: 'desc', label: 'Descending' },
] as const;

function Select({ value, onChange }) {
  return <Dropdown options={SORT_OPTIONS} value={value} onChange={onChange} />;
}
// ❌ WRONG: Static JSX element (fallback, empty state) recreated each render
function UserList({ users }) {
  const emptyState = <p className="empty">No users found</p>;
  return users.length ? <List items={users} /> : emptyState;
}

// ✅ CORRECT: Hoisted as module-level constant
const EMPTY_STATE = <p className="empty">No users found</p>;

function UserList({ users }) {
  return users.length ? <List items={users} /> : EMPTY_STATE;
}

Only hoist truly static values. Values that depend on props or state must stay inside the component.


DOM Batching

Multiple sequential DOM reads and writes cause layout thrashing — the browser must recalculate layout between each interleaved read/write. Batch reads together, then writes together.

// ❌ WRONG: Interleaved read/write forces layout recalculation 3 times
function resizeElements(elements: HTMLElement[]) {
  elements.forEach(el => {
    const height = el.getBoundingClientRect().height; // READ — triggers layout
    el.style.height = `${height * 2}px`;             // WRITE
    const width = el.getBoundingClientRect().width;  // READ — triggers layout again
    el.style.width = `${width * 2}px`;              // WRITE
  });
}

// ✅ CORRECT: Read all first, then write all — single layout recalculation
function resizeElements(elements: HTMLElement[]) {
  // Read phase
  const dimensions = elements.map(el => el.getBoundingClientRect());

  // Write phase
  elements.forEach((el, i) => {
    el.style.height = `${dimensions[i].height * 2}px`;
    el.style.width = `${dimensions[i].width * 2}px`;
  });
}

In React, most DOM manipulation is handled by the reconciler automatically. Apply manual batching only when working with imperative DOM access in refs or third-party libraries.


Memoized Selectors and Computations

Derived data that depends on expensive transforms should be memoized with useMemo. For global state (Redux, Zustand), use selector memoization to prevent unnecessary re-renders.

// ❌ WRONG: Expensive filter runs on every render, even when todos hasn't changed
function TodoList({ todos, filter }) {
  const filtered = todos
    .filter(t => t.status === filter)
    .sort((a, b) => b.priority - a.priority);

  return <List items={filtered} />;
}

// ✅ CORRECT: Memoize the derived value
function TodoList({ todos, filter }) {
  const filtered = useMemo(
    () => todos
      .filter(t => t.status === filter)
      .sort((a, b) => b.priority - a.priority),
    [todos, filter],
  );

  return <List items={filtered} />;
}
// ✅ Zustand: memoized selector prevents re-render when other state changes
const completedCount = useStore(
  useShallow(state => state.todos.filter(t => t.completed).length)
);

// ✅ Redux Toolkit: createSelector for memoized derived state
import { createSelector } from '@reduxjs/toolkit';

const selectCompletedTodos = createSelector(
  (state: RootState) => state.todos.items,
  (items) => items.filter(t => t.completed),
);

Apply useMemo when the computation is measurably expensive (>1ms), involves sorting/filtering large arrays, or when the result is passed as a prop to a memoized child.


Common Pitfalls

Passive listeners on elements that need preventDefault: Custom scroll containers that trap scroll must use { passive: false }. Overuse of passive: true will cause swallowed preventDefault() calls to silently fail.

Hoisting values that reference props/state: Hoisted module-level constants cannot access component scope. Only hoist pure data and static JSX with no dependencies.

useMemo with cheap computations: Adding useMemo to a trivial computation (array of 5 items, simple string concat) adds overhead without benefit. Profile before memoizing.