Breaking The React Wall

13 Jan 2025 - Ben

I’ve written before about using useImperativeHandle to hoist functionality up a layer of React componentry. It helps better encapsulate any state that component defines, without needing to expose it directly to the consumer.

You can use this same feature to “exit” React! If your top level component is named App, and it exposes a ref, you can access that with a ref callback! The simplest implementation looks like:

const App = React.forwardRef(function (props, ref) {
  const [msg, setMsg] = useState(props.defaultText || "Hi there!");
    useImperativeHandle(ref, () => ({
      update(newMsg) { setMsg(newMsg) }
    }), [setMsg]);
  return <div>{msg}</div>;
});

const renderRoot = ReactDOM.createRoot(document.querySelector('#react-root');
function refFunc(refAttr: RefAttributes | null) {
    // attach it directly to the window for our example
    window.refHandle = refAttr;
}
renderRoot.render(<App ref={refFunc} />);

Then running window.refHandle.update("Hello World"); in the console updates the text within App! If you try it in your console window on this page, the text in the box below will change!

It’s a very simple example, but it shows that we can get React to react to our changes, called outside of the React context, in a manner consistent with React’s guidelines. Although it seems trivial here, I’ve seen it talked about very little! Most React libraries feel like they try to fit the web into React’s paradigm, instead of allowing React to do what it’s best at (reacting to changes) and giving us an escape hatch to also interact with other elements in the browser.

In more complicated “App” components, using an imperative handle means we don’t need to worry about calling “render” again. The functions we expose act inside the React lifecycle, even though we’re calling them from outside. The same thing could be achieved by placing functions on any element with in the ref, but by able to “come out the top” like this, we can place react DOM render roots anywhere we want, or isolate our consumer from React entirely by wrapping our component in a Custom Element.

It’s important that we aren’t just copying out a reference to a setState function or something like that. While that technically works, it breaks a lot of assumptions React and most developers would make about your code. It’s a good way to cause a mess! Comparatively, using an imperative handle is something React gives us, and it exposes functionality in a way that we can easily see what’s going on. Data still flows one way, and callback are the primary method of interacting upstream. The ref gives us a way to encapsulate some imperative logic as well!


PS: You can see the script used in your dev tools, or at https://benkenawell.com/scripts/breaking-react-wall.js. It looks a little different than the above script because it’s in React 19, strictly javascript, doesn’t use JSX, and I’m vendoring react and react-dom.