skip to content

A React chart wrapper lists its `data` prop in the dependency array of the effect that constructs the chart, so every data update destroys and rebuilds it — the chart flickers and the user's zoom is lost. How would you restructure the component?

level: seniorimportance: should knowfreq 42%

answer

  1. one effect creates, another synchronises
  2. empty deps for the lifecycle
  3. instance in a ref, guard for null
  4. object props change identity every render
  5. two-way binding needs an echo guard

basics

~20 s

Separate lifecycle from synchronisation. One effect with an empty dependency array constructs and destroys the instance, and separate effects react to each prop by calling the library's imperative setters on the instance held in a ref.

solid answer

~50 s

The effect is doing two jobs that have different triggers. I split them. A lifecycle effect with empty deps constructs the chart, stores it in a ref, and destroys it in cleanup — it runs on mount and unmount only, so nothing the user built up inside the instance is thrown away. Then one small effect per prop, each depending on that prop, reads the instance from the ref, bails out if it is not there yet, and calls the library's own setter — for Chart.js that is assigning `chart.data` and calling `chart.update()`; for a map it is `setView`. Two things to watch. Object and array props change identity on every parent render, so the sync effect fires constantly unless the caller memoizes them or the effect compares before writing. And if the library is also an input source, its change callback feeding state back into the same prop creates an echo loop, so the sync effect must skip the write when the library already holds that value.

code

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

export function BarChart({ data }) {
  const canvasRef = useRef(null);
  const chartRef = useRef(null);

  // lifecycle: mount and unmount only
  useEffect(() => {
    const chart = new Chart(canvasRef.current, {
      type: 'bar',
      data: { labels: [], datasets: [] },
    });
    chartRef.current = chart;
    return () => {
      chart.destroy();
      chartRef.current = null;
    };
  }, []);

  // sync: react to one prop through the library's own setter
  useEffect(() => {
    const chart = chartRef.current;
    if (!chart) return;
    chart.data = data;
    chart.update();
  }, [data]);

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

go deeper

for a junior

Know the split: the effect that creates the instance takes an empty dependency array, and prop changes are pushed through a second effect that calls the library's own methods.

for a middle

Explain why a reactive dependency on the lifecycle effect forces a teardown and rebuild, and why the instance must come from a ref that the sync effect guards against being null.

for a senior

Show the production consequences — lost zoom and selection, tile re-requests, redraw storms from object props that change identity — and handle the two-way echo loop when the library also produces values.

for a principal

Decide the contract at the API level: which props are construction-only and therefore remount via key, which are synchronised, and who owns memoizing object props, so every wrapper in the codebase answers those questions the same way.

## Two jobs, two triggers A wrapper around an imperative library has to answer two unrelated questions: *when does this object exist* and *when does this object need to be told something*. The first is a lifecycle, driven by mount and unmount. The second is a reaction, driven by individual props. Putting both in one effect ties them together — and since a dependency array can only express "re-run everything", any reactive dependency forces a full teardown and rebuild. The cost is not theoretical. A rebuilt chart loses zoom and pan, restarts animations, and drops any selection; a rebuilt map re-requests tiles; a rebuilt editor loses the cursor and the undo history. The user sees a flash and loses their place, and the network sees a burst of redundant requests. ## The shape ```js const chartRef = useRef(null); useEffect(() => { // lifecycle const chart = new Chart(canvasRef.current, baseConfig); chartRef.current = chart; return () => { chart.destroy(); chartRef.current = null; }; }, []); useEffect(() => { // sync const chart = chartRef.current; if (!chart) return; chart.data = data; chart.update(); }, [data]); ``` Ordering works out because effects run top to bottom after the same commit: on mount the lifecycle effect creates the instance before the sync effect reads the ref. The `if (!chart) return` guard still earns its place, because a sync effect can also run after the lifecycle effect's cleanup in a StrictMode remount or a future re-order. ## One effect per prop, not one big sync effect It is tempting to write a single effect depending on `[data, theme, zoom]` that pushes everything. It works, but it calls three setters whenever any one of them changes, and `update()` on a chart or `setView()` on a map is not free — it redraws, animates, or re-requests tiles. Small effects keep the reaction proportional to the change, and they read as documentation: this prop maps to that setter. ## Referential identity is the usual noise source `data` is an object or array, so it is a new value on every parent render unless the caller memoized it. `Object.is` sees a different reference and the sync effect fires, redrawing a chart whose numbers did not change. Options: have the caller pass a stable value; derive the value with `useMemo` in the wrapper from primitives; or keep the last-applied value in a ref and skip the setter when a cheap comparison says nothing moved. Which one you pick is a judgment call about who owns the prop, but ignoring it means the wrapper redraws on every unrelated parent render. ## The echo loop When the library is also an input — an editor, a slider, a map the user pans — the pattern becomes a two-way binding, and two-way bindings loop. The library fires a change callback, you set state, the prop changes, the sync effect writes the value back into the library, which may fire another change. The visible symptoms are the cursor jumping to the end of the text, the map fighting the user's drag, or an infinite update cycle. The fix is a guard in the sync direction: read the library's current value first and write only if it genuinely differs, or track the last value you pushed in a ref and ignore the echo. ## Construction-only options Some options a library only honours in its constructor. Do not add them to the lifecycle effect's deps and call that prop syncing — you would be back to rebuilding, just less obviously. Give the wrapper a `key` derived from the option so React unmounts the old component and mounts a fresh one; that reuses the teardown and setup you already trust instead of a hand-rolled rebuild path. ## Reading fresh props inside the lifecycle effect The lifecycle effect legitimately wants the *current* value of a prop at construction time, or inside a long-lived callback it registers, without becoming reactive to it. Keeping the value in a ref updated on each render works and is the widely-understood idiom; React 19 also offers `useEffectEvent` for wrapping a callback that reads the latest props without being listed as a dependency. Either way, the point is the same: reading a value is not a reason to re-run the lifecycle. ## What not to do Do not call setters during render — that is a side effect in a pure function and will double-fire in development. Do not read the instance ref during render either; refs are not a render-time data source. And do not "solve" the churn by removing the dependency array from the sync effect; an effect with no deps runs after every render and turns a targeted update into a redraw storm.

  • The sync effect fires on every parent render even when the numbers are unchanged. What is happening?
    The prop is an object or array recreated by the parent on each render, so React's identity comparison sees a new dependency. Either the caller memoizes the value, or the wrapper derives it from primitives with useMemo, or the effect keeps the last applied value in a ref and skips the setter when a cheap comparison shows no real change.
  • The library is an editor that also reports changes back into state. What extra care does the sync effect need?
    It becomes a two-way binding, so it can echo. Before writing, compare against the value the library currently holds — or against the last value you pushed, kept in a ref — and skip the write when they match. Without that, every user keystroke round-trips back into the library and the cursor jumps or the updates loop.
  • An option the library only accepts in its constructor has to change at runtime. Now what?
    Do not add it to the lifecycle effect's dependencies. Put a `key` on the wrapper component derived from that option: React unmounts the old instance and mounts a new one, so the change flows through the cleanup and setup you already wrote and tested rather than a special rebuild branch you would have to maintain separately.

saying these in an interview costs you the question

  • Rebuilds the instance whenever a data prop changes
  • Uses one effect with no dependency array to push all props
  • Reads the instance ref during render instead of in an effect
  • Assumes React compares dependency objects deeply
  • Ignores the echo loop when the library is also an input

context