React18 - Automatic Batching, Transition

Doodream·2023년 1월 8일
0

React

목록 보기
20/20
post-thumbnail

새로운 기능들이 추가된 React18 - 1

React 18 ?

이번에 React 18이 새롭게 공개되면서 리액트 개발팀은 주로 아래와 같은 이슈를 해결하고자 노력하였습니다.

  • New Root API
  • Automatic Batching
  • New Concept “Transition”
  • SSR support for Suspence

New Root API

// 18v 이전의 루트
ReactDOM.render(<App/>, document.getElementById(‘root’));
// 18v 이후의 루트 API
import * as ReactDOMClient from ‘react-dom/client’;
const container = document.getElementById(‘app’);
const root = ReactDOMClient.createRoot(container);
root.render();

이전에는 Root가 되는 컨테이너에 아무런 변화가 없더라도 render를 하기위해서는 반드시 root를 체크하고 통과해야만 했습니다. React 가 Virtual DOM을 사용하기 때문에 반드시 거쳐야 하는 작업입니다. 하지만 이런 반복되고 무의미한 과정을 개선하기 위해서 새로운 Root API 를 18 버전에 적용하게 되었습니다.

새로운 API의 createRoot() 함수를 사용하면 Root 를 반환합니다. 새로운 Root 를 통해서 React Node를 DOM에 Render 할 수 있습니다. 또한 원한다면 Unmount 도 할 수 있습니다. 즉, 업데이트를 할때 다시 DOM을 거치지 않고 바로 Root에서 렌더링 할 수 있게되었습니다.

import * as ReactDOMClient from "react-dom/cleint";
const root = ReactDOMClient.createRoot(container);
root.render(<App />);
root.unmount();

ReactDOM.hydrateRoot()

import * as ReactDOMClient from 'react-dom/client';
import App from 'App';

const container = document.getElementById('app');
// root를 생성하고 렌더링
const root = ReactDOMClient.hydrateRoot(container, <App />);
// render 함수는 따로 사용할 필요가 없다.

기존에는 서버에서 만들어진 엘레멘트를 바탕으로 클라이언트에서 hydrate()를 이용하여 필요한 부분만 렌더링이 하는 하이드레이션 업데이트만 해줬다. 즉, 렌더링은 렌더링, 하이드레이션은 하이드레이션이었던 것

하지만 위 코드 처럼 react18은 아래와 같이 hydrateRoot를 통해 초기 형태의 JSX를 받으며 서버사이드에서 초기렌더링을 거치고 이후에 container를 하이드레이션 하여 업데이트 하겠다고 하였다. 코드가 간결화 된 점이다.

사실 지금은 NextJs를 사용하기 때문에 크게 와닿지 않는 업데이트다.

Automatic Batching

function handleClick() {
  setCount((prev) => prev + 1);
  setFlag((f) => !f);
  //위 두 업데이트 모두 배칭되어서 단 한번 리랜더링 된다!
}
setTimeout(() => {
  setCount((prev) => prev + 1);
  setFlag((f) => !f);
  //setTimeout 내에서 업데이트도 배칭되어 한번의 리렌더링을 하게된다.
}, 1000);
fetch("api").then(() => {
  setCount((prev) => prev + 1);
  setFlag((f) => !f);
  //fetch api에서 또한 배칭되어 한번의 리렌더링을 하게된다.
});

원래 일반적인 함수 안에서 setState 상태 업데이트가 이뤄지면 한꺼번에 배칭이 이뤄지기 때문에 한번만 리렌더링 되는 렌더링 최적화가 있어왔는데 setTimeout 안과 fetching api 에서는 setState 마다 여러번의 렌더링을 거쳐서 렌더링되어왔다. 하지만 이제는 해당 부분안에서도 리렌더링 최적화가 이루어지게 되었다.

✅ 그러한 최적화를 원하지 않을 경우에는 flushSync()을 사용하면된다.

flushSync()

import {flushSync} from 'react-dom';

const [test, setTest] = useState(0)

const handleTestClick = () => {
    flushSync(() => {
      setTest((prev) => prev + 1)
      console.log(test)
    })
    flushSync(() => {
      setTest((prev) => prev + 1)
      console.log(test)
    })
  }

  useEffect(() => {
    console.log(test, 'count')
  }, [test])

0 'count'
0
1 'count'
0
2 'count'

위의 경우 초기렌더링을 제외하고 총 리렌더링이 2회 이루어집니다.

Transition (useTrasition)

기존에는 setState 에 대해 업데이트 우선순위를 정하기 어려웠습니다. 따라서 쓰로틀링 디바운싱 기술을 통해서 나 데이터의 흐름을 기반으로 렌더링 순서를 맞추거나 해왔습니다. 특히 전자 같은 경우 시간을 정해야 했기 때문에 더빠른 사용자 경험을 제공하기가 어려웠습니다.

import React, { ChangeEventHandler, useState, useTransition } from 'react'
import type { NextPage } from 'next'

const Home: NextPage = () => {
  const [text, setText] = useState('')
  const [displayValue, setDisplayValue] = useState('')
  const [isPending, startTransition] = useTransition()

  const handleChange: ChangeEventHandler<HTMLInputElement> = (e) => {
    setText(e.target.value)
    const value =
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value
    startTransition(() => setDisplayValue(value))
  }

  return (
    <div>
      <input value={text} onChange={handleChange} />
      {isPending ? (
        <h2>loading....</h2>
      ) : (
        <>
          {Array.from({ length: displayValue.length }).map((_, key) => {
            return <div key={key}>{Math.round(Math.random())}</div>
          })}
        </>
      )}
    </div>
  )
}

export default Home

아래와 같은 상황을 생각해봅시다.

검색창에서 사용자가 입력을 했을 때 추천 검색어가 나와야 하는 상황입니다.
그때 검색창에 검색하고나서 바로 추천 검색어가 나오는데 이 과정이 오래걸린다면 사용자가 추가로 입력하는 입력창 업데이트가 느려 질 수 있습니다. 따라서 추가로 입력하는 업데이트를 더 우선 적으로 업데이트 한다는 것 ( 리렌더링에 대한 우선순위 ) 를 결정하게 만드는게 useTrasition 입니다.

startTransition의 콜백으로 우선순위를 낮출 상태 업데이트 함수를 넣습니다.
isPending은 우선순위가 높은 상태 업데이트가 진행되고 낮은 상태 업데이트가 밀리고 있는 상황을 보여줍니다.

위 동영상을 보면 입력창이 매우 매끄럽지는 않지만 확실하게 우선순위가 더 높게 렌더링되는 상황을 볼 수 있습니다.

useTrasition을 적용하지 않은 코드와 비교해봅시다.

import React, { ChangeEventHandler, useState, useTransition } from 'react'
import type { NextPage } from 'next'

const Home: NextPage = () => {
  const [text, setText] = useState('')
  const [displayValue, setDisplayValue] = useState('')
  // const [isPending, startTransition] = useTransition()

  const handleChange: ChangeEventHandler<HTMLInputElement> = (e) => {
    setText(e.target.value)
    const value =
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value +
      e.target.value
    // startTransition(() => setDisplayValue(value))
    setDisplayValue(value)
  }

  return (
    <div>
      <input value={text} onChange={handleChange} />
      {/*{isPending ? (*/}
      {/*  <h2>loading....</h2>*/}
      {/*) : (*/}
      {/*  <>*/}
      {/*    {Array.from({ length: displayValue.length }).map((_, key) => {*/}
      {/*      return <div key={key}>{Math.round(Math.random())}</div>*/}
      {/*    })}*/}
      {/*  </>*/}
      {/*)}*/}
      <>
        {Array.from({ length: displayValue.length }).map((_, key) => {
          return <div key={key}>{Math.round(Math.random())}</div>
        })}
      </>
    </div>
  )
}

export default Home

좀더 극적인 효과를 보여주기 위해서는 로딩 화면 보다는 ispending 기준으로 css 먹이는게 더 효과적으로 보일 것입니다.

네이버 개발팀에서 해당 기능에 대한 성능 측정을 비교해본 결과 아래와 같은 화면이 도출되었습니다.

useTrasition은 동시성을 활용하여 계산합니다. 즉, 렌더링 과정을 잘게 쪼갬으로서 우선순위가 먼저인 렌더링을 우선으로 처리하고 다시 원래 처리하려던 렌더링과정으로 넘어가 렌더링을 하는 것 입니다.
이런식으로 렌더링 과정을 동시성 처리를 함으로서 렌더링 우선순위를 가져갈 수 있습니다.

profile
일상을 기록하는 삶을 사는 개발자 ✒️ #front_end 💻

0개의 댓글