[React 19] 공식문서 톺아보기 - React DOM 클라이언트 API

Study
리액트 루트를 생성하는 createRoot와 hydrateRoot에 대해 공부하였습니다.

createRoot

createRoot로 브라우저 DOM 노드 안에 React 컴포넌트를 표시하는 루트를 생성할 수 있습니다.

const root = createRoot(domNode, options?)
const root = createRoot(domNode, options?)
import { createRoot } from 'react-dom/client';

const domNode = document.getElementById('root');
const root = createRoot(domNode);

root.render(<App />);
import { createRoot } from 'react-dom/client';

const domNode = document.getElementById('root');
const root = createRoot(domNode);

root.render(<App />);
  • createRoot를 호출하면 브라우저 DOM 엘리먼트 안에 콘텐츠를 표시할 수 있는 React 루트를 생성합니다.
  • 루트를 생성한 후에는 root.render를 호출해 그 안에 React 컴포넌트를 표시해야 합니다.
  • 일반적으로 한번만 호출하며, 페이지에서 리액트를 부분부분 사용하는 경우에는 여러번 호출할수도 있다.

주의사항

  • 앱이 서버에서 렌더링 되는 경우 createRoot()는 사용할 수 없습니다. 대신 hydrateRoot()를 사용해야합니다.
  • 컴포넌트의 자식이 아닌 DOM 트리의 다른 부분(예: 모달 또는 툴팁)에 JSX 조각을 렌더링하려는 경우, createRoot 대신 createPortal을 사용합니다.

반환값 root.render(reactNode)

  • root.render를 호출하여 JSX 조각(“React 노드”)을 React 루트의 브라우저 DOM 노드에 표시합니다.
  • reactNode: 표시하려는 React 노드. 일반적으로 <App />과 같은 JSX 조각이 되지만, createElement()로 작성한 React 엘리먼트, 문자열, 숫자, nullundefined 등을 전달할 수도 있습니다.
  • root.render를 처음 호출하면 React는 React 컴포넌트를 렌더링하기 전에 React 루트 내부의 모든 기존 HTML 콘텐츠를 지웁니다.
  • 동일한 루트에서 render를 두 번 이상 호출하면, React는 필요에 따라 DOM을 업데이트하여 사용자가 전달한 최신 JSX를 반영합니다. React는 이전에 렌더링 된 트리와 “비교”해서 재사용할 수 있는 부분과 다시 만들어야 하는 부분을 결정합니다.
  • root.render(<App />); 는 비동기적으로 동작할 수 있으므로 동작 타이밍이 중요한 경우에는 flushSync를 이용하여 동기화할 수 있다.

hydrateRoot

  • 서버에서 미리 생성된 HTML이 이미 존재하는 경우 (예: Next.js, Gatsby 등의 프레임워크 사용 시), 일반적인 ReactDOM.render() 또는 ReactDOM.createRoot()를 사용하면 서버에서 생성된 기존 HTML을 무시하고 클라이언트 측에서 처음부터 새로 렌더링하게 됩니다.
  • 이를 해결하기 위해서 hydrateRoot()를 사용하면
    • 서버에서 생성된 기존 HTML을 재사용
    • 해당 HTML에 React 이벤트 핸들러만 "부착"(hydrate)
    • 불필요한 초기 렌더링을 건너뛰어 성능 향상
  • 장점
    • 성능: 전체 앱을 다시 렌더링하지 않아 초기 로딩이 빠름
    • SEO: 서버에서 생성된 HTML을 그대로 사용하므로 검색 엔진에 친화적
    • 사용자 경험: 콘텐츠가 즉시 표시된 후에 상호작용 가능

반환값 root.unmount()

  • root.unmount를 호출하면 React 루트 내부에서 렌더링된 트리를 삭제합니다.

  • 온전히 React만으로 작성된 앱에는 일반적으로 호출될 일이 없다.

  • React가 관리하는 DOM 노드가 React 외부의 코드(jQuery 등)에 의해 강제로 제거되는 경우, DOM에서는 제거되었지만 React의 클린업 함수는 실행되지 않고 구독중인것도 여전히 유지되는 문제가 발생한다. 이를 해결하기 위해 사용한다.

  • 주의사항

    • root.unmount를 한 번 호출한 후에는 같은 루트에서 root.render를 다시 호출할 수 없습니다.
    • 다만 createRoot(domNode, options?)를 통해 root를 생성할때 사용한 domNode는 재사용할 수 있다.
    const root1 = ReactDOM.createRoot(domNode);
    root1.render(<NewApp />);
    
    // 마운트 해제
    root1.unmount();
    
    // 오류 발생! (같은 루트 인스턴스 재사용 시도)
    // root1.render(<NewApp />); // Error: Cannot update an unmounted root
    
    // domNode 재사용 가능
    const root2 = ReactDOM.createRoot(domNode);
    root2.render(<NewApp />);
    const root1 = ReactDOM.createRoot(domNode);
    root1.render(<NewApp />);
    
    // 마운트 해제
    root1.unmount();
    
    // 오류 발생! (같은 루트 인스턴스 재사용 시도)
    // root1.render(<NewApp />); // Error: Cannot update an unmounted root
    
    // domNode 재사용 가능
    const root2 = ReactDOM.createRoot(domNode);
    root2.render(<NewApp />);

프로덕션에서의 오류 로깅

기본적으로 React는 모든 오류를 콘솔에 기록합니다. 자체 오류 보고를 구현하려면 선택적 오류 처리기 루트 옵션인 onUncaughtError, onCaughtError와 를 제공할 수 있습니다 onRecoverableError.

import { createRoot } from 'react-dom/client';

import { reportCaughtError } from './reportError';

const container = document.getElementById('root');
const root = createRoot(container, {
  onCaughtError: (error, errorInfo) => {
    if (error.message !== 'Known error') {
      reportCaughtError({
        error,
        componentStack: errorInfo.componentStack,
      });
    }
  },
});
import { createRoot } from 'react-dom/client';

import { reportCaughtError } from './reportError';

const container = document.getElementById('root');
const root = createRoot(container, {
  onCaughtError: (error, errorInfo) => {
    if (error.message !== 'Known error') {
      reportCaughtError({
        error,
        componentStack: errorInfo.componentStack,
      });
    }
  },
});
  • 에러가 발생했을때 콘솔에 표기하는게 아니라 자체 로깅시스템에 보낼때 유용

hydrateRoot

hydrateRoot는 이전에 react-dom/server로 생성된 HTML 콘텐츠를 가진 브라우저 DOM 노드 안에 React 컴포넌트를 표시할 수 있게 해줍니다.

const root = hydrateRoot(domNode, reactNode, options?)
const root = hydrateRoot(domNode, reactNode, options?)
import { hydrateRoot } from 'react-dom/client';

const domNode = document.getElementById('root');
const root = hydrateRoot(domNode, reactNode);
import { hydrateRoot } from 'react-dom/client';

const domNode = document.getElementById('root');
const root = hydrateRoot(domNode, reactNode);
  • hydrateRoot()는 렌더링된 컨텐츠가 서버에서 렌더링된 컨텐츠와 동일할 것을 기대합니다. 따라서 불일치 사항은 버그로 취급하고 수정해야 합니다.
  • 서버에서 렌더링된 HTML(SSR)에 React를 "붙이는"(hydrate) 과정입니다. 이때 React는 기존 DOM 구조를 재사용하면서 이벤트 핸들러 등을 연결합니다.
    • 그래서 사전에 HTML을 구성하지 않고 hydrateRoot() 만 실행할 경우는 아무런 동작도 실행하지 않는다.
  • 앱에서 hydrateRoot 호출이 단 한번만 있을 가능성이 높습니다. 프레임워크를 사용한다면, 프레임워크가 이 호출을 대신 수행할 수도 있습니다.
  • 앱을 사전에 렌더링된 HTML 없이 클라이언트에서 직접 렌더링한다면(CSR), hydrateRoot()는 지원되지 않습니다. createRoot()를 대신 사용해야합니다.
  • hydrateRoot를 호출하여 이미 서버 환경에서 렌더링된 기존 HTML에 React를 “붙여넣기” 합니다.
  • 반환값 root에 대해서 root.render(reactNode)root.unmount() 실행이 가능하다.

createRoot 와 다른 점

  • createRoot 에 들어가는 domNode 는 비어있어야한다. hydrateRoot에 들어가는 domNode 는 서버에서 렌더링된 HTML이 있어야한다.
  • hydrateRoot 를 호출할때는 reactNode 를 인자로 반드시 전달해야하며 domNode하고 비교하여 불일치 하는 부분만 클라이언트에서 리렌더링한다.
    • 클라이언트에서 리렌더링하기 위해서는 추가로 root.render(<App />); 에 대한 코드가 필요하다.
    • 만약 ssr만 지원하는 경우는 hydrateRoot만 실행하고, 별도의 render() 호출을 하지 않아도 된다.
  • createRoot 는 새로 Virtual DOM 생성 후 전체 렌더링을 하고 hydrateRoot 는 기존 DOM과 Virtual DOM 비교 후 Hydration 을 진행한다.

Reconciliation 과정의 경우, hydrateRootroot.render 에서 모두 거치는데

  • hydrateRoot : 초기 hydration 시 서버 DOM과 클라이언트 트리의 일치 여부를 검증
  • root.render : 상태 변화 시 이전 Virtual DOM과 새 Virtual DOM을 비교

주의할점

  • 루트가 Hydrate를 완료하기 전에 root.render를 호출하면, React는 서버에서 렌더링된 HTML을 모두 없애고 클라이언트에서 렌더링된 컴포넌트들로 완전히 교체합니다.
  • hydrateRoot 가 완료되고나서 root.render를 호출할때 VDOM 트리(Virtual DOM)가 이전과 완전히 동일하다면 React는 실제 DOM에 어떠한 변화도 주지 않습니다.

Hydration 오류 원인들

  • React를 통해 만들어진 HTML의 루트 노드안에 공백 혹은 개행같은 추가적인 공백.
  • typeof window !== 'undefined'과 같은 조건을 렌더링 로직에서 사용.
  • window.matchMedia같은 브라우저에서만 사용가능한 API를 렌더링 로직에 사용.
  • 서버와 클라이언트에서 서로 다른 데이터를 렌더링.

서로 다른 클라이언트와 서버 컨텐츠 다루기

  • 의도적으로 서버와 클라이언트에서 다른 것을 렌더링 할 수 있다.
  • 다만 서버에서 미리 HTML을 구성하는 경우는 두번 렌더링해야하기 때문에 성능에 문제가 생길수있다.

Hydration된 루트 컴포넌트를 업데이트하기

  • Hydration이 끝난 후에, root.render를 호출해 React 컴포넌트를 업데이트 할 수 있습니다. createRoot와는 다르게 HTML로 최초의 컨텐츠가 이미 렌더링 되어 있기 때문에 자주 사용할 필요는 없습니다
  • Hydration 후 어떤 시점에 root.render를 호출한다면, 그리고 컴포넌트의 트리 구조가 이전에 렌더링했던 구조와 일치한다면, React는 State를 그대로 보존합니다. 입력 창Input에 어떻게 타이핑하든지 간에 문제가 발생하지 않습니다. 즉, 아래 예시에서처럼 매초 마다 상태를 업데이트하는 반복적인 render를 문제 없이 렌더링 한다는 것을 알 수 있습니다.
import { hydrateRoot } from 'react-dom/client';

import App from './App.js';
import './styles.css';

const root = hydrateRoot(document.getElementById('root'), <App counter={0} />);

let i = 0;
setInterval(() => {
  root.render(<App counter={i} />);
  i++;
}, 1000);
import { hydrateRoot } from 'react-dom/client';

import App from './App.js';
import './styles.css';

const root = hydrateRoot(document.getElementById('root'), <App counter={0} />);

let i = 0;
setInterval(() => {
  root.render(<App counter={i} />);
  i++;
}, 1000);
읽어주셔서 감사합니다