Command Palette

Search for a command to run...

Hectal
PHASE 8Intermediate ~28 min· topic 6 of 6

Topic 8.6

Working with Non-React Code

In one line

To use a widget that wasn't built for React (a chart, a map, an editor), give it a DOM node through a ref, create it in an effect, keep it in sync with props in another effect and destroy it in the cleanup; use flushSync only when you must read the updated DOM right after a state change.

Think of it like this

A guest chef in your restaurant kitchen. You give them their own counter, tell them what to cook, pass them new orders as they come in, and clean the counter when they leave. You don't reach into their pans, and they don't touch your stoves. A non-React widget is that guest chef: React gives it one empty element (its counter) and talks to it only through its methods.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Third-party widget
A ready-made piece of UI from another library, such as a chart or map, often not written for React.
Instance
One object created with new, like one chart. You keep it in a ref to call its methods later.
Container
The empty element React gives the widget to draw in. React never puts children in it.
destroy
The widget method that removes its DOM and listeners. You call it in the effect cleanup.
Synchronising
Keeping something outside React matching React's props and state, the job effects are for.
flushSync
A react-dom function that makes React apply a state update to the DOM immediately.
Batching
React grouping several state updates and applying them together after the handler finishes.
Canvas
An HTML element you draw on with JavaScript, pixel by pixel. Many chart libraries use it.

Step by step

01Give the widget its own element

Render an empty element with a ref. Nothing in your JSX ever goes inside it.

Chart.jsxwhole filejsx
const canvasRef = useRef(null);
const chartRef = useRef(null);

return <canvas ref={canvasRef} width={300} height={140} />;

02Create it once, destroy it in the cleanup

An effect with [] runs after the first commit, when the ref holds the element. The cleanup runs on unmount, and in StrictMode development it runs once extra right after mounting, which proves the cleanup works.

Chart.jsxwhole filejsx
useEffect(() => {
  const chart = new BarChart(canvasRef.current);
  chartRef.current = chart;
  return () => {
    chart.destroy();
    chartRef.current = null;
  };
}, []);

03Sync props into the widget

A second effect calls the widget's update method whenever the data changes. Effects run in the order they're written, so on mount the widget already exists when this one runs.

Chart.jsxwhole filejsx
useEffect(() => {
  chartRef.current.setData(data);
}, [data]);
Sync props into the widgetdiagram
Rendering diagram…

04Send widget events back to React

Pass the widget a stable function that calls the latest callback from a ref. The widget never needs to be recreated when the parent passes a new function.

Rating.jsxwhole filejsx
const onChangeRef = useRef(onChange);
useEffect(() => {
  onChangeRef.current = onChange; // always the latest
});

// when creating the widget:
new StarRating(boxRef.current, {
  onChange: (v) => onChangeRef.current(v),
});

05Need the new DOM right now? flushSync

Without flushSync, the DOM still shows the old list on the line after setMessages. With it, React commits first, so the new last item exists and you can scroll to it.

Chat.jsxwhole filejsx
import { flushSync } from "react-dom";

function send() {
  flushSync(() => {
    setMessages([...messages, text]);
  });
  listRef.current.lastElementChild.scrollIntoView();
}
Need the new DOM right now? flushSyncdiagram
Rendering diagram…

Try it yourself

  1. 1

    Forget the cleanup in StrictMode

    In the star example, wrap <App /> in <StrictMode> (import it from react) and delete return () => widget.destroy();. Predict how many rows of stars appear. (Two: StrictMode mounts, cleans up and mounts again, and with no cleanup the first widget stays.) Put the cleanup back and there is one row.

  2. 2

    Recreate the widget on every change

    In the chart example, add data to the first effect's dependencies and move setData into it. Click New data and watch the console. (Every change logs chart destroyed and chart created: correct, but wasteful for a heavy library, and it would reset things like zoom.)

  3. 3

    Replace flushSync with an effect

    In the flushSync example, remove flushSync and scroll in useEffect(() => { listRef.current.lastElementChild.scrollIntoView({ block: "nearest" }); }, [messages]). Both boxes now scroll to the new message. When would you still need flushSync? (When the code that needs the new DOM must run inside the handler itself, for example before handing control to a library.)

Code & diagrams

Wrap a canvas chart class with a ref and two effects New tab

The chart is created once, redrawn each time `data` changes, and destroyed when the component unmounts. React renders only the empty `<canvas>`; the class does all the drawing.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

chart created
chart drew [ 3, 5, 2, 6 ]
chart drew [ 4, 6, 3, 1 ]
chart destroyed
A vanilla widget that talks back to React New tab

Two directions, one source of truth. A click in the widget calls `onChange`, React state changes, and the sync effect tells the widget. A change from React (Clear) goes through the same effect.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

widget shows 3
widget shows 5
You rated it 5 / 5
widget shows 0
flushSync: update the DOM before scrolling New tab

Without `flushSync` the handler still sees the old list, so it scrolls to `Good` and the new message stays hidden. With it, the new `<li>` is already in the DOM. Click Send a few times in each box to feel the difference.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

without flushSync, last item is: Good
with flushSync, last item is: Message 6
Wrapping a real map library (not live: needs a package)jsx
import { useEffect, useRef } from "react";
import L from "leaflet";            // npm install leaflet
import "leaflet/dist/leaflet.css";

export function CityMap({ center, zoom = 12, onMove }) {
  const boxRef = useRef(null);
  const mapRef = useRef(null);
  const onMoveRef = useRef(onMove);
  useEffect(() => { onMoveRef.current = onMove; });

  useEffect(() => {
    const map = L.map(boxRef.current);
    L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
      attribution: "© OpenStreetMap contributors",
    }).addTo(map);
    map.on("moveend", () => onMoveRef.current?.(map.getCenter()));
    mapRef.current = map;
    return () => map.remove();      // Leaflet's destroy
  }, []);

  useEffect(() => {
    mapRef.current.setView(center, zoom);
  }, [center[0], center[1], zoom]); // compare numbers, not a new array each render

  return <div ref={boxRef} style={{ height: 320 }} />;
}

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Call flushSync inside an effect

Inside a useEffect, call flushSync(() => setCount(1)).

terminal
$ Console
── what you'll see ──
flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering. Consider moving this call to a scheduler task or micro task.

Break #2

Let React render children into the widget's container

In the star example, render <div ref={boxRef}>{value === 0 && <span>No rating</span>}</div>. Make the widget remember its container (this.container = container) and redraw it in setValue with this.container.replaceChildren(this.row), as many widgets do. Press Clear, then click a star.

terminal
$ Preview and console
── what you'll see ──
After Clear, "No rating" never appears. Clicking a star then crashes the app:
Uncaught NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
An error occurred in the <span> component.
 
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.

Myth vs fact

Myth

You can't use non-React libraries in a React app.

Fact

You can: give them a DOM node with a ref, create and destroy them in an effect, and sync props in another effect. That's how most React wrappers for charts and maps are built.

Myth

flushSync makes your app faster.

Fact

It makes one update synchronous, which is usually slower: it skips batching and blocks the browser until React has committed. Use it only when code must read the updated DOM immediately.

Myth

Create the widget in the component body so it exists on first render.

Fact

During render the container element doesn't exist yet, and rendering must be pure. Create widgets in an effect, after the commit.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Split create and update effects: re-creating a heavy widget (a map with tiles, an editor with history) on every prop change throws away its internal state and costs a lot. Create once with [], sync each prop in its own small effect with precise dependencies (numbers, not new arrays).

  • ▸

    flushSync also flushes any other pending updates, and since React 18 it is the escape hatch from automatic batching. It is also handy inside beforeprint handlers or when integrating with view transitions and animation libraries that snapshot the DOM.

  • ▸

    When the widget has its own state that React also shows (a map's centre), pick one leader. Usually the widget leads during user interaction (React mirrors it via onMove), and React leads for programmatic changes (calls setView). Compare before calling to avoid ping-pong loops: if (!map.getCenter().equals(center)) map.setView(center).

  • ▸

    For a widget that must not be created on the server (it touches window or document), load it in the effect with a dynamic import() so server rendering (Phase 20) never runs it, and guard the cleanup against the effect finishing after unmount.

Remember this

  1. 1

    Many great libraries are not React components: charting libraries that draw on a <canvas>, map libraries such as Leaflet or MapLibre, code editors, video players, date pickers. They usually work the same way: new Widget(element, options) builds its own DOM inside element, methods such as setData() or setView() update it, and destroy() removes it. Some have official React wrappers; when they don't, or when the wrapper lags behind, you wrap them yourself.

  2. 2

    The wrapping recipe. (1) Render an empty container and attach a ref: <div ref={boxRef} />. React will never put children in it, so the widget owns it. (2) In an effect with [], create the widget with boxRef.current and keep the instance in another ref. (3) Return a cleanup that calls destroy(), so unmounting (and StrictMode's extra mount, Topic 7.4) leaves nothing behind. (4) In a second effect that depends on the props, call the widget's update methods, like chart.setData(data). This is synchronising, exactly what effects are for (Topic 7.1).

  3. 3

    Events coming back. Widgets report user actions through callbacks or events (onChange, map.on("moveend")). Pass a function that calls React state setters or the parent's callback. Because the widget is created once, it would hold the callback from the first render forever. Store the latest callback in a ref (or, in React 19.2, read it with useEffectEvent, Topic 7.9) so the widget always calls the current one.

  4. 4

    **flushSync. React batches state updates and applies them after your event handler finishes (Topic 4.5). Usually that's what you want. But sometimes the next line of your handler needs the DOM already updated**: you add a message and want to scroll to it, or a non-React library must measure the new element right now. flushSync(() => setMessages(next)) from react-dom makes React render and commit that update synchronously, before flushSync returns. The next line sees the new DOM.

  5. 5

    flushSync is a sharp tool. It forces a full render and commit immediately, skips batching and can hurt performance, so use it only when code must read the DOM right after an update and an effect won't do. It can't be called while React is rendering or committing: inside an effect or a layout effect it prints an error and doesn't flush. In most cases an effect that runs after the commit (Topic 8.2's chat scroll) is the simpler answer.

  6. 6

    Rules that keep the peace: never let the widget touch DOM that React renders, and never let React render children into the widget's container. Keep one source of truth: if both React state and the widget hold a value (the selected rating, the map centre), decide which one leads, and sync the other in one direction at a time so they don't fight in a loop.

Explain it without notes

01

How do you wrap a non-React widget, such as a chart library, in a React component?

02

Why split the widget's creation and its updates into two effects?

03

What does flushSync do, when do you need it, and why should it be rare?

04

How do events from the widget reach React without recreating the widget when the callback changes?

Practice

01

Wrap this class in a Ticker component: class Ticker { constructor(el) { this.el = el; } start(text) { this.el.textContent = text; } stop() { this.el.textContent = ""; } }. The component takes a text prop and must call start whenever it changes and stop on unmount.

02

Write a todo list where Add appends an item and then focuses the new item's input in the same handler.

Trade-offs

  • ↔

    Wrapping a library yourself gives full control and no extra dependency, but you own the sync logic; a maintained official React wrapper saves that work when one exists.

  • ↔

    flushSync gives immediate DOM updates at the cost of batching and responsiveness; an effect after the commit is usually cheaper and simpler.

Done when you can

  • I can wrap a non-React widget with a container ref, a create/destroy effect and a sync effect.

  • I can pass widget events back to React without recreating the widget.

  • I keep React's children and the widget's DOM in separate elements.

  • I can use flushSync to read the updated DOM in an event handler.

  • I know flushSync can't be used during rendering or in effects, and why it should be rare.