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-domfunction 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.
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.
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.
useEffect(() => {
chartRef.current.setData(data);
}, [data]);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.
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.
import { flushSync } from "react-dom";
function send() {
flushSync(() => {
setMessages([...messages, text]);
});
listRef.current.lastElementChild.scrollIntoView();
}Try it yourself
- 1
Forget the cleanup in StrictMode
In the star example, wrap
<App />in<StrictMode>(import it fromreact) and deletereturn () => 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
Recreate the widget on every change
In the chart example, add
datato the first effect's dependencies and movesetDatainto it. Click New data and watch the console. (Every change logschart destroyedandchart created: correct, but wasteful for a heavy library, and it would reset things like zoom.) - 3
Replace flushSync with an effect
In the flushSync example, remove
flushSyncand scroll inuseEffect(() => { listRef.current.lastElementChild.scrollIntoView({ block: "nearest" }); }, [messages]). Both boxes now scroll to the new message. When would you still needflushSync? (When the code that needs the new DOM must run inside the handler itself, for example before handing control to a library.)
Code & diagrams
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
Console
Nothing logged yet.Expected console output
chart created
chart drew [ 3, 5, 2, 6 ]
chart drew [ 4, 6, 3, 1 ]
chart destroyedTwo 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
Console
Nothing logged yet.Expected console output
widget shows 3
widget shows 5
You rated it 5 / 5
widget shows 0Without `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
Console
Nothing logged yet.Expected console output
without flushSync, last item is: Good
with flushSync, last item is: Message 6import { 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)).
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.
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). - ▸
flushSyncalso flushes any other pending updates, and since React 18 it is the escape hatch from automatic batching. It is also handy insidebeforeprinthandlers 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 (callssetView). 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
windowordocument), load it in the effect with a dynamicimport()so server rendering (Phase 20) never runs it, and guard the cleanup against the effect finishing after unmount.
Remember this
- 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 insideelement, methods such assetData()orsetView()update it, anddestroy()removes it. Some have official React wrappers; when they don't, or when the wrapper lags behind, you wrap them yourself. - 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 withboxRef.currentand keep the instance in another ref. (3) Return a cleanup that callsdestroy(), 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, likechart.setData(data). This is synchronising, exactly what effects are for (Topic 7.1). - 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 withuseEffectEvent, Topic 7.9) so the widget always calls the current one. - 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))fromreact-dommakes React render and commit that update synchronously, beforeflushSyncreturns. The next line sees the new DOM. - 5
flushSyncis 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
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
How do you wrap a non-React widget, such as a chart library, in a React component?
Why split the widget's creation and its updates into two effects?
What does flushSync do, when do you need it, and why should it be rare?
How do events from the widget reach React without recreating the widget when the callback changes?
Practice
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.
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.
- ↔
flushSyncgives 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
flushSyncto read the updated DOM in an event handler.I know
flushSynccan't be used during rendering or in effects, and why it should be rare.