Back to All Articles
reactjavascriptfrontendperformance

Mastering React 19 DOM APIs: Portals, Synchronous Updates & Modern Hydration

Learn how to use React 19 DOM APIs like createPortal and flushSync correctly, and understand which legacy methods were removed.

2026-10-15
Mastering React 19 DOM APIs: Portals, Synchronous Updates & Modern Hydration

Today we are looking at how to interact directly with the browser DOM using modern React 19 DOM APIs while avoiding deprecated patterns.

What you'll learn

  • How to render modals and tooltips outside the root tree using createPortal.
  • When and why to force synchronous DOM mutations with flushSync.
  • Navigating the entry points between client rendering (react-dom/client) and server rendering (react-dom/server).
  • Safely upgrading away from removed React 19 APIs like render and hydrate.

Rendering Outside the Root with createPortal

Sometimes your component hierarchy forces a DOM node to be clipped by overflow settings or positioned weirdly. createPortal lets you punch a hole through your tree and render children anywhere in the DOM.

TSX
import { useState } from 'react';
import { createPortal } from 'react-dom';

export function ModalBadge() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div style={{ position: 'relative' }}>
      <button onClick={() => setIsOpen(true)}>Open Modal</button>
      {isOpen &&
        createPortal(
          <div className="modal-backdrop" onClick={() => setIsOpen(false)}>
            <div className="modal-content" onClick={(e) => e.stopPropagation()}>
              <p>Portal content rendered directly in document.body</p>
              <button onClick={() => setIsOpen(false)}>Close</button>
            </div>
          </div>,
          document.body
        )}
    </div>
  );
}

Speed Note: Portals only change the DOM node location, not the React context tree. Events still bubble up through your React component tree, even if the DOM nodes are miles apart.

Forcing Synchronous Layout with flushSync

React usually batches state updates for performance. Rarely, you might need to force the browser to recalculate layout immediately after a state change before the next line of code runs. flushSync handles this edge case.

TSX
import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';

export function SyncTodoList() {
  const [todos, setTodos] = useState(['Learn React', 'Build an app']);
  const [text, setText] = useState('');
  const listRef = useRef<HTMLUListElement>(null);

  function handleAdd() {
    flushSync(() => {
      setTodos((prev) => [...prev, text]);
      setText('');
    });
    
    // DOM is guaranteed to be updated right here
    if (listRef.current) {
      listRef.current.scrollTop = listRef.current.scrollHeight;
    }
  }

  return (
    <div>
      <ul ref={listRef} style={{ maxHeight: '100px', overflowY: 'auto' }}>
        {todos.map((t, i) => (
          <li key={i}>{t}</li>
        ))}
      </ul>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={handleAdd}>Add Todo</button>
    </div>
  );
}

Gotcha: flushSync hurts performance because it forces React to flush updates early and interrupt batching. Use it only when integrating with third-party libraries that rely on immediate DOM measurements.

Clean Client Entry Points with react-dom/client

In React 19, legacy top-level rendering methods are gone. Your entry point should always use createRoot for client-side applications.

TSX
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';

const container = document.getElementById('root');

if (!container) {
  throw new Error('Failed to find the root element');
}

const root = createRoot(container);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

Migration Tip: If you see errors about ReactDOM.render or ReactDOM.hydrate, you are likely upgrading an older codebase. Replace ReactDOM.render(, container) with createRoot(container).render().

Removing Legacy APIs in React 19

React 19 cleaned up legacy APIs that have been deprecated for years. If your codebase relies on findDOMNode or hydrate, your build will break until you refactor.

  • findDOMNode is removed. Attach a useRef directly to your DOM element instead.
  • hydrate is replaced entirely by hydrateRoot from react-dom/client.
  • unmountComponentAtNode is replaced by calling root.unmount().
TSX
// OLD (Removed in React 19)
// ReactDOM.hydrate(<App />, container);

// NEW (React 19 standard)
import { hydrateRoot } from 'react-dom/client';

hydrateRoot(container, <App />);

Summary & Next Steps

React DOM APIs give you fine-grained control over browser interaction when standard component rendering isn't quite enough. Keep your bundle fast, avoid synchronous work unless necessary, and keep your client entry points updated to React 19 standards.

  • Audit your codebase for deprecated APIs like findDOMNode or old render calls.
  • Experiment with createPortal for accessible overlays and dropdown menus.