skip to content

A teammate wraps a charting library in a React function component by calling `new Chart(document.getElementById('chart'), config)` directly in the component body, above the returned JSX. What is wrong with initializing it there?

level: juniorimportance: should knowfreq 45%

answer

  1. render is a pure calculation
  2. nothing is in the DOM yet
  3. ids are global, refs are per-instance
  4. create in an effect, destroy in cleanup

basics

~20 s

Component bodies must stay pure. That call runs again on every render, targets a node React has not created yet on the first render, and a document lookup collides across instances. Initialize in an effect against a ref instead.

solid answer

~50 s

Three separate problems. First, rendering has to be a pure calculation: React may run a component function more than once for a single update, and in development StrictMode does so deliberately, so a constructor in the body creates instances nobody holds a reference to and nobody destroys. Second, on the first render the DOM node does not exist yet — React only creates real nodes when it commits, so `document.getElementById('chart')` returns `null` and the constructor throws or silently does nothing. Third, an id is a document-global name, so a second instance of the component addresses the first one's node, and `document` is not defined at all during server rendering, which is exactly where the component body runs. The working shape is `<canvas ref={canvasRef} />` in the JSX, the constructor inside a `useEffect`, and `chart.destroy()` in that effect's cleanup.

code

javascript · 25 lines
javascript
import { useEffect, useRef } from 'react';
import { Chart } from 'chart.js/auto';

const config = {
  type: 'bar',
  data: {
    labels: ['a', 'b', 'c'],
    datasets: [{ label: 'sales', data: [3, 7, 5] }],
  },
};

export function TinyChart() {
  const canvasRef = useRef(null);
  const chartRef = useRef(null);

  useEffect(() => {
    chartRef.current = new Chart(canvasRef.current, config);
    return () => {
      chartRef.current.destroy();
      chartRef.current = null;
    };
  }, []);

  return <canvas ref={canvasRef} />;
}

go deeper

for a junior

Be ready to say plainly that render must not touch the DOM, and that a library gets initialized inside useEffect against a ref, with its destroy call in the cleanup you return.

for a middle

Explain the render-versus-commit split: why the node is absent during the first render, why a document lookup breaks with two instances, and why the instance belongs in a ref rather than state.

for a senior

Show the failure in production terms — accumulating detached nodes, listeners and animation loops from constructors that re-ran on every render — and how you would spot it in a heap snapshot or the Performance panel.

for a principal

Frame it as an ownership boundary: your codebase needs one blessed wrapper pattern for imperative libraries, so that purity, teardown and server rendering are decided once instead of relitigated in every feature that pulls in a widget.

## What a component body is allowed to do A React function component is a pure calculation: given props and state it returns elements describing what the UI should look like, and it touches nothing outside itself while it runs. React depends on that. It may call a component more than once for a single visible update, it may throw a render away and restart it when a higher-priority update arrives, and in development `<StrictMode>` invokes component functions twice on purpose so that impure code announces itself immediately. `new Chart(...)` is not a calculation. It builds a long-lived object that takes ownership of a canvas, attaches DOM listeners, and often starts an animation loop. Put it in the body and it runs an unpredictable number of times, and every copy except the last is unreachable garbage that no code path will ever destroy. ## The node does not exist during render React works in two phases. During the **render phase** it builds a tree of elements; nothing has been put in the document yet. During the **commit phase** it creates the real DOM nodes and attaches them, and only after that does it run effects. So during the first render of the component, the `<canvas>` it returns has not been created — there is nothing to find. ```js function TinyChart() { // first render: returns null, constructor blows up const el = document.getElementById('chart'); new Chart(el, config); return <canvas id="chart" />; } ``` On later renders the node happens to exist, which is worse than a clean failure: the bug becomes intermittent and looks like a race condition rather than a lifecycle mistake. ## Global lookups are the wrong addressing scheme Even when the element is there, an id is a document-wide name while a component is a template that can be instantiated many times. Render two of these components and both find the first canvas, so one chart draws over the other and the second container stays blank. And `document` does not exist during server rendering — the component body is precisely the code that runs on the server, so a bare `document` reference throws before any HTML is produced. A ref fixes both. `useRef` gives each component instance its own box; React writes *this* instance's node into `ref.current` when it commits, and effects never run on the server, so the initialization is naturally client-only. ## The shape that works ```js function TinyChart() { const canvasRef = useRef(null); const chartRef = useRef(null); useEffect(() => { chartRef.current = new Chart(canvasRef.current, config); return () => { chartRef.current.destroy(); chartRef.current = null; }; }, []); return <canvas ref={canvasRef} />; } ``` The effect runs after the commit, so `canvasRef.current` is the attached node. The instance lives in a second ref rather than in state, because it is not data the UI renders and writing it to state would schedule a re-render for no visual reason. The empty dependency array means "set up on mount, tear down on unmount", and the returned cleanup is what makes the component safe to unmount and remount. ## What "runs on every render" costs Leave the constructor in the body and every state change in the component re-runs it: a fresh chart per keystroke, each with its own listeners and animation frames, none of them destroyed. The old canvases stay reachable from the library's internals, so the heap climbs, frame time goes to redundant redraws, and the visible chart restarts its animation on every render. This is the classic detached-node leak, and a heap snapshot of it usually just says "a lot of canvases". ## Two near-miss fixes to reject Wrapping the constructor in `useMemo` looks like "run once" but is not a lifecycle. `useMemo` is a render-phase cache, React is allowed to discard cached values and recompute them, it still runs before the DOM exists, and it gives you nowhere to put `destroy()`. A module-level `initialized` flag makes the symptom disappear for one instance, breaks the second, and still tears nothing down. The only correct home for "create this, and here is how to take it apart again" is an effect that returns a cleanup function.

  • If the body has to stay pure, why is `useMemo` not an acceptable place to construct the instance either?
    `useMemo` is a render-phase cache, not a lifecycle. It still runs during render, before the DOM node exists; React is free to discard the cached value and recompute it, so you can get more than one instance; and it offers no place to call `destroy()`. Anything that needs teardown belongs in an effect.
  • Would `useLayoutEffect` instead of `useEffect` change any of this?
    Not the purity or the node-availability problem — both run after the commit with the node attached. `useLayoutEffect` runs before the browser paints, so it is worth the cost only when the library measures or positions itself and you would otherwise see a visible flash of an unsized widget. It blocks paint, so it is not a free upgrade.
  • The library's module touches `window` at import time and crashes server rendering. What do you do?
    The effect itself never runs on the server, so the initialization is already client-only — the problem is the import. Move it inside the effect with a dynamic `import()` and store the instance once it resolves, ignoring the result if the effect has already been cleaned up. Keeping the wrapper in a client component is a prerequisite.

saying these in an interview costs you the question

  • Says the component body is fine because it only runs once
  • Reaches for document.getElementById instead of a ref
  • Guards initialization with a module-level boolean flag
  • Uses useMemo to get run-once semantics for a live object
  • Creates the instance but never destroys it

context