Post

React v18 변경 사항 톺아보기

React v18

React에서 중요시하는 동시성에 관련한 사항들과 Suspense가 주요 업데이트 사항인것 같다.

Automatic Batching (자동 일괄 처리)

batching 처리란?

동일한 클릭 이벤트 내에 두 개의 상태 업데이트가 있는 경우 React는 항상 이를 하나의 재렌더링으로 일괄 처리한다. 다음 코드를 실행하면 클릭할 때마다 상태를 두 번 설정하더라도 React는 단일 렌더링만 수행하는 것을 볼 수 있다. 불필요한 재렌더링을 줄여 성능을 확보하는 렌더링 방식이였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    setCount((c) => c + 1); // 리렌더링이 일어나지않음
    setFlag((f) => !f); // 리렌더링이 일어나지않음
    // React에선 한번의 렌더링으로 일괄처리
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style=>{count}</h1>
    </div>
  );
}

하지만 React에서 일괄 처리는 일관성이 없었다. 예를들어 다른곳에서 데이터를 불러와 State를 업데이트하는 경우엔 각자 독립적인 렌더링이 일어났다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    fetchSomething().then(() => {
      // 콜백 이밴트는 이벤트가 종료됐다고 판단하여 일괄처리가 되지 않음
      setCount((c) => c + 1); // 리렌더링
      setFlag((f) => !f); // 리렌더링
    });
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style=>{count}</h1>
    </div>
  );
}

위와 같이 react 이벤트 핸들러가 아닌 Promise, setTimeout, 기본 이벤트 핸들러 등에서는 React에서 일괄처리를 할 수 없었다.

Automatic Batching (자동 일괄 처리)

React v18에서는 createRoot 내에 모든 업데이트는 출처와 상관없이 자동으로 일괄 처리하게끔 변경 되었다.

아까와 같은 코드로 예를 들자면 아래와 같고 batching을 차단하고 싶으면 flushSync API를 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function App() {
  // React 18 버전 이후
  import { flushSync } from "react-dom";

  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    fetchSomething().then(() => {
      flushSync(() => {
        setCount((prev) => prev + 1);
      });
      // 리렌더링
      setCount((c) => c + 1);
      setFlag((f) => !f);
      // 그 후로 한 번의 리렌더링
    });
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style=>{count}</h1>
    </div>
  );
}

적용하기 위해선 새로 추가된 react-dom/client API인 createroot 태그로 루트를 생성해야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// React v17
import * as ReactDOMClient from "react-dom/client";

function App() {
  return (
    <div>
      <h1>Hello World</h1>
    </div>
  );
}

const rootElement = document.getElementById("root");

ReactDOMClient.render(<App />, rootElement, () => console.log("renderered"));

위와 같은 방식에서 아래와 같은 방식으로 변경할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import * as ReactDOMClient from "react-dom/client";

function App({ callback }) {
  // Callback will be called when the div is first created.
  return (
    <div ref={callback}>
      <h1>Hello World</h1>
    </div>
  );
}

const rootElement = document.getElementById("root");

const root = ReactDOMClient.createRoot(rootElement);
root.render(<App callback={() => console.log("renderered")} />);

ConCurrent Feature (동시성 기능)

동시성

리엑트는 항상 동시성을 추구하고 있다.

그러나 자바스크립트는 싱글 스레드기반 언어로 여러 작업을 동시에 처리할 수 없다.
그래서 ConCurrent Mode를 사용해 동시에 작업이 처리되는 것처럼 기능들을 확대하고 있었다.

  1. 여러 작업을 작은 단위로 나눈 후 작업들 간의 우선순위를 정한다.
  2. 정해진 우선순위에 따라 작업을 수행한다.

즉 실제로는 동시에 작업이 실행되지 않지만 사용자 입장에서는 작업 간 전환이 매우 빨라 동시에 작업이 진행되는 걸로 보인다.

React v18 에서는 동시성을 기능으로 제공하기 위해서 긴급 업데이트와 긴급하지 않은 업데이트를 구분할 수 있는 개념을 추가했다.

Transitions

Urgent updates 는 입력, 클릭, 누르기 등과 같은 직접적인 상호 작용을 반영 (ex. input 입력) Transition updates 는 UI를 한 보기에서 다른 보기로 전환 (ex. 검색 필터 변경)

아래와 같이 startTransition을 사용해 타이핑, 클릭, 스크롤 등에서 쓰로틀링이나 디바운싱같은 처리 없이도 렌더링이 완료되기 전에 변경된 최신 결과만을 보여줄 수 있다.

1
2
3
4
5
6
7
8
9
10
import { startTransition } from "react";

// 긴급: 입력한 내용 표시
setInputValue(input);

// 내부 상태 업데이트를 Transition으로 후순위로 넘김
startTransition(() => {
  // 쿼리 결과 표시
  setSearchQuery(input);
});

startTransition에 래핑된 업데이트는 긴급하지 않은 것으로 처리되며 클릭이나 키 누름과 같은 더 긴급한 업데이트가 들어오는 경우 중단된다.

전환이 사용자에 의해 중단되면(예: 여러 문자를 연속으로 입력) React는 다음을 throw하고, 완료되지 않은 오래된 렌더링 작업을 제거하고 최신 업데이트만 렌더링된다.

transition은 hook 형태와 API 형태가 있는데 사용용도는 다음과 같다.

  • useTransition: 보류 상태를 추적하는 값을 포함하여 전환을 시작하는 훅
  • startTransition: 후크를 사용할 수 없을 때 트랜지션을 시작하는 방식

쉽게말해 useTransition은 지금 순위가 낮은 업데이트가 보류중인지 여부를 알려주는 값을 포함한 훅이다.

useTransition 사용법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useTransition } from "react";

function App() {
  // 지연 시간을 초기화 할 수 있다.
  const [isPending, startTranstion] = useTransition({ timeoutMs: 1000 });

  function updateSearchQuery(e) {
    useTransition(() => {
      // 쿼리 결과 표시
      setSearchQuery(e.event.value);
    });
  }

  return <div id="app">{isPending && <p>Current Updating...</p>}</div>;
}

useDeferredValue

위에 useTranstion과 동일하다고 볼 수 있는 기능이다. 긴급하지 않은 부분이 다시 렌더링하는 것을 연기할 수 있으며, 어찌 보면 디바운싱과 같지만 고정된 시간 지연이 없어서 React는 첫 번째 렌더링을 진행한 후에 지연된 렌더링을 시도한다.

useTrnasaction과 가장 큰 차이는 useTransaction은 상태를 업데이트하는 코드를 래핑하는 반면에 useDeferredValue는 상태를 업데이트하면서 영향 받는 값들을 래핑한다.

아래와 같이 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
function ProductList({ products }) {
  const deferredProducts = useDeferredValue(products);
  return (
    <ul>
      {deferredProducts.map((product) => (
        <li>{product}</li>
      ))}
    </ul>
  );
}

useSyncExternalStore

아래에 훅이 나온 배경을 설명하기 전에 낯설 수 있는 단어를 먼저 설명하려고 한다.

Extarnal Store
외부 저장소는 subscribe할 수 있는 모든 것이다. (Redux, 전역 변수, 모듈 범위 변수, DOM 상태 등)

Internal Store 내부 저장소에는 props, context, useState, useReducer가 포함된다.

React v18 부터는 동시성 렌더링을 반영하면서 Tearing이란 문제가 일어나기 시작했다.
Tearing은 시각적인 불일치를 나타내며, UI가 동일한 상태에 대해서 여러 값을 표시하는 것을 의미한다.

React v18부터 렌더링을 중지하기 때문에 일시 정지 사이에 업데이트는 렌더링에 사용되는 데이터와 관련된 변경사항을 가져올 수 있다.

동기 렌더링에서는 아래와 같이 UI는 일관성을 유지할 수 있었다. syncRendering

아래에 동시 렌더링 시에는 처음에 파란색이였다가. React에서 외부 스토어가 변경되어 빨간색으로 렌더링을 계속하면서 Tearing을 유발한다. concurrent

위와 같은 이유로 useExternalStore hook을 사용해 스토어 내에 데이터를 올바르게 가져올 수 있도록 한다.

useSyncExternal 훅은 두 가지 기능을 사용할 수 있다.

  • subscribe - 콜백 함수를 등록하는 함수
  • getSnapshot - subscribe 된 값이 마지막 렌더링 이후 변경되었는지 렌더링 되었는지 확인하고, 문자열이나 숫자같은 변경할 수 없는 값이거나 캐싱된 객체인지 확인하여 immutable한 값이 반환된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import {useSyncExternalStore} from 'react';

  or

// Backwards compatible shim
import {useSyncExternalStore} from 'use-sync-external-store/shim';

//Basic usage. getSnapshot must return a cached/memoized result
useSyncExternalStore(
  subscribe: (callback) => Unsubscribe
  getSnapshot: () => State
) => State

// Selecting a specific field using an inline getSnapshot
const selectedField = useSyncExternalStore(store.subscribe, () => store.getSnapshot().selectedField);

getSnapshot

1
2
3
4
5
6
7
8
9
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector";

const selection = useSyncExternalStoreWithSelector(
  store.subscribe,
  store.getSnapshot,
  getServerSnapshot,
  selector,
  isEqual
);

Suspense Features

React v16.6 버전부터 Suspense로 로드 상태를 명시적으로 지정할 수 있었다. 하지만 React.lazy를 이용한 분할 코드였고, 서버에서 렌더링할 때에는 사용할 수 없었다.

그래서 React v18부터는 Suspense를 확장하여 비동기 작업(로드 코드, 데이터, 이미지 등)을 처리할 수 있도록 변경되었다.

아래와 같은 코드로 사용할 수 있으며 작업중에는 fallback 파라미터로 넘기는 엘리먼트가 표시된다.

1
2
3
4
5
6
7
8
9
<div>
  {showComments && (
    <Suspense fallback={<Spinner />}>
      <Panel>
        <Comments />
      </Panel>
    </Suspense>
  )}
</div>

useId

useId는 클라이언트와 서버측에서 모두 고유한 id를 생성하는데 사용할 수 있으며 아래와 같은 형태로 사용할 수 있다.

1
2
3
4
5
6
7
8
9
function Checkbox() {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>Do you like React?</label>
      <input id={id} type="checkbox" name="react" />
    </>
  );
}

useInsertionEffect

useEffect와 동일하지만 DOM이 변경 후 그리고 레이아웃 전에 동기적으로 실행되는 훅이다. 레이아웃을 읽기 전에 스타일을 DOM에 삽입하려면 해당 훅을 사용하면 된다. 레이아웃 전이기 때문에 ref로 엘리먼트에 액세스할 수 없다.

useInsertEffect는 CSS-in-JS 렌더링 도중 스타일을 삽입할 때 생기는 성능 문제를 해결하는 용도이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
function useCSS(rule) {
  useInsertionEffect(() => {
    if (!isInserted.has(rule)) {
      isInserted.add(rule);
      document.head.appendChild(getStyleForRule(rule));
    }
  });
  return rule;
}
function App() {
  let className = useCSS(rule);
  return <div className={className} />;
}
This post is licensed under CC BY 4.0 by the author.