Modern React Deep Dive: 리액트 17, 18 변경사항

현재 인터넷에서 가장 많이 쓰이는 리액트 버전은 16 버전이다.

그 이후 버전인 17, 18 버전에서 어떠한 변화가 있었는지 살펴보자.

리액트 17 버전

리액트 17 버전은 새롭게 추가된 기능이 없으며 기존 코드의 수정을 필요로 하는 변경 사항이 최소화되어있다.

이전 버전과의 호환성을 중시하며 17 버전으로 이동하기 쉽도록 만들어져 있다.

이벤트 위임 방식 변경

이전 버전에서는 이벤트 핸들러가 문서의 루트(Document)에서 이벤트를 캐치했지만,

리액트 17에선 각 루트마다 개별적으로 붙게 되었다.

다른 라이브러리 혹은 아래에 설명할 두 개 이상의 리액트가 한 애플리케이션에 공존할 때 이벤트 버블링으로 인한 혼란을 방지하기 위한 수정이다.

점진적인 업그레이드

일종의 업데이트를 위한 업데이트로 위의 이벤트 위임 방식 변경으로 인해 전체 애플리케이션에서 일부분만 리액트 버전을 업그레이드 할 수 있게 되었다.

내부에서 다른 버전의 리액트를 lazy loading하고 그 과정에 별도의 Root 요소를 만든 후, 거기에 불러온 리액트 모듈을 렌더링하는 구조로 되어있다.

결과적으로 하나의 애플리케이션에 두 개의 리액트가 존재하게 되어 일부분만 업데이트 하는 것이 가능하다.

리액트 팀에서는 물론 한꺼번에 리액트를 업그레이드하는 게 좋다고 말한다.

새로운 JSX 변환

이전 버전에서는 JSX가 바벨을 통해 자바스크립트로 변환이 되면 JSX가 React.createElement 메소드로 변환되므로 이를 수행하는데 필요한 import React from 'react' 문구가 필수였다.

17 버전에서는 새로운 JSX 변환 방식을 적용하여 JSX를 변환할 때 필요한 모듈인 react/jsx-runtime을 불러오는 구문도 같이 추가되므로 컴포넌트 파일에 import React from 'react'를 쓰지 않아도 된다.

이 변경점 덕분에 필요없는 import문을 제거했기 때문에 번들링 사이즈를 줄이는 효과가 있다.

useEffect 클린업 함수 비동기 실행

useEffect의 클린업 함수는 16 버전까지는 동기적으로 처리됐다.

동기적으로 실행되기 때문에 완료되기 전까지 다른 작업을 방해하므로 불필요한 성능 저하로 이어지는 문제가 있었다.

리액트 17 버전에선 화면이 완전히 업데이트 된 후에 클린업 함수가 비동기적으로 실행된다, 다시 말해 컴포넌트의 커밋 단계가 완료될 때까지 지연되게 되었다.

이를 통해 약간의 성능적인 이점을 볼 수 있게 됐다.

컴포넌트 undefined 반환에 대한 일관적인 처리

리액트 16, 17 버전에선 컴포넌트가 undefined를 반환하면 에러가 나게 되어있다. 이는 의도치 않은 잘못된 반환으로 인한 실수를 방지하기 위한 것이었다.

리액트 16 에선 forwardRef나 memo 컴포넌트에서 undefined를 반환하는 경우에 별다른 에러가 없었는데, 이를 17 버전에선 동일하게 에러가 나도록 변경되었다.

리액트 18 버전

17 버전이 점진적인 업그레이드를 위한 준비에 가까운 버전이었다면, 18 버전은 다양한 기능들이 추가되었다.

새로 추가된 훅

  • useId: 컴포넌트마다 내부에 고유한 값을 만들어준다.

  • useTransition: UI 변경을 가로막지 않고 상태를 업데이트할 수 있다. 동시성을 다루는 새로운 훅.

function TabContainer() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState < Tab > "about";

  function selectTab(nextTab: Tab) {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <>
      {isPending ? (
        "로딩 중"
      ) : (
        <>
          {tab === "about" && <About />}
          {tab === "posts" && <Posts />}
          {tab === "contact" && <Contact />}
        </>
      )}
    </>
  );
}

isPending은 상태가 업데이트 진행 중인지 알아볼 수 있는 boolean,
startTransition은 긴급하지 않은 상태 업데이트로 간주할 setState 함수를 넣어둘 수 있는 함수를 인자로 받아서 실행해준다.

  • useDeferredValue: 리렌더링이 급하지 않은 부분을 지연할 수 있게 도와주는 훅.
function Input() {
  const [text, setText] = useState("");
  const deferredText = useDeferredValue(text);

  const list = useMemo(() => {
    const arr = Array.from({ length: deferredText.length }).map(
      () => deferredText
    );

    return (
      <ul>
        {arr.map((str, index) => (
          <li key={index}>{str}</li>
        ))}
      </ul>
    );
  }, [deferredText]);

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    setText(e.target.value);
  }

  return (
    <>
      <input value={text} onChange={handleChange} />
      {list}
    </>
  );
}

디바운스와 비슷한 기능이지만 리액트에 특화된 기능으로 주어진 값에 대한 "지연된" 버전을 제공함으로써 높은 우선순위의 업데이트가 완료된 이후에 작업이 처리될 수 있도록 한다.

  • useSyncExternalStore: 리액트에서 관리할 수 없는 외부 데이터 소스의 값에 대해 테어링을 방지하는 훅.
function subscribe (callback: (this: Window, ev: UIEvent) => void) {
  window.addEventListener("resize", callback);
  return () => {
    window.removeEventListener("resize", callback);
  };
}

function useWindowWidth () {
  const windowSize = useSyncExternalStore(
    subscribe,
    () +> window.innerWidth,
    () => 0,
  )

  return windowSize
}
  • useInsertionEffect: useEffect와 시그니처가 같지만 DOM이 실제로 변경되기 전에 동기적으로 실행된다. CSS-in-JS 라이브러리를 위한 훅이다.
    useInsertionEffect (DOM이 변경되기 전) -> useLayoutEffect (DOM이 변경된 이후) -> useEffect (커밋 단계 이후) 순으로 실행된다.

react-dom/client

  • createRoot: 기존 react-dom에 있던 render메소드를 대체하는 메소드다.

  • hydrateRoot: SSR 애플리케이션에서 하이드레이션을 하기 위한 새로운 메소드다.

둘은 모두 18 버전의 새로운 기능들을 사용하기 위해서 추가된 메소드다.

react-dom/server

  • renderToPipeableStream: 리액트 컴포넌트를 HTML로 렌더링하는 메소드. renderToNodeStream을 대체하는 메소드로, 비동기적으로 동시성모드를 지원한다.

  • renderToReadableStream: 웹 스트림을 기반으로 작동한다.

Automatic Batching

여러 상태 업데이트를 하나의 리렌더링으로 묶어서 성능이 향상되었다.

function handleClick() {
  setCount((c) => c + 1);
  setFlag((f) => !f);
}

위와 같이 상태 업데이트를 하면 17 미만 버전에서 실행하면 렌더링이 두번 일어나지만 17 버전부터는 하나의 리렌더링으로 묶이게 되고,

function handleClick() {
  fetchSomething().then(() => {
    setCount((c) => c + 1);
    setFlag((f) => !f);
  })
}

18 버전부턴 위와 같이 비동기 처리 도중에도 자동으로 하나의 리렌더링으로 묶이게 된다.

특이점으로 하나의 비동기작업 단위별로 batching 된다.

엄격 모드(Strict Mode)

컴포넌트가 최초 마운트될 때 자동으로 모든 컴포넌트를 마운트 해제하고 두 번째 마운트에서 이전 상태를 복원하게 된다.

이는 이후에 있을 변경(상태를 유지하면서 UI의 섹션을 추가하고 제거하는 기능)을 위해 의도적으로 수정된 부분으로,

마치 useEffect가 두번 실행된 것 처럼 보이게 된다.

Suspense 기능 강화

서스펜스가 정식지원 되면서 몇 가지 변경점이 생겼다.

  • 마운트 되기 직전에 effect가 빠르게 실행되는 문제 해결.

  • Suspense로 인해 컴포넌트가 보이거나 사라질 떄에도 effect가 정상적으로 실행된다.

  • Suspense를 서버에서도 실행할 수 있게 되었다.

  • 스로틀링이 추가되어 최대한 자연스럽게 보여주도록 노력한다.

그 밖의 변경 사항

  • 컴포넌트에서 undefined를 반환해도 에러가 발생하지 않는다. (null반환과 동일하게 처리.)