[React] Deep Dive 모던 리액트(26) 리액트 18 추가된 훅
FrontEnd/Deep Dive

[React] Deep Dive 모던 리액트(26) 리액트 18 추가된 훅

728x90

 

 

이전 글에서는 리액트 17의 변경점에 대해서 다뤘었다. 17이 점진적인 업그레이드를 위한 준비를 했다면 리액트 18에서는 다양한 기능들이 추가되었다. 변경점들을 하나씩 알아보자!

 

 

useId

useId는 컴포넌트 별로 유니크한 값을 생성해주는 새로운 훅이다. 컴포넌트 내부에서 사용할 수 있는 유니크한 값을 생성하는 것은 생각보다 쉽지 않다. 하나의 컴포넌트가 여러 곳에서 재사용되는 경우나 컴포넌트 트리에서 컴포넌트가 가지는 모든 값이 달라야 한다는 제약을 고려해야하기 때문이다.

 

만약 아래와 같은 코드가 서버사이드 렌더링이 된다고 생각해보자.

 

export default function UniqueComponent(){
	return <div>{Math.random()}</div>
}

 

 

해당코드는 오류를 내보내게 된다. 하이드레이션을 했을때의 Math.random()값과 서버에서 렌더링을 했을때의 Math.random()값이 다르기 때문이다. 이런 이유로 서비스에서 컴포넌트 별로 고유한 값을 사용하게 하는것은 리액트 17까지는 까다로운 작업이었다.

 

useId를 사용하면 이 문제를 쉽게 해결할 수 있다.

 

 

import { useId } from "react";

function Child() {
  const id = useId();
  return <div>child : {id}</div>;
}

function SubChild() {
  const id = useId();

  return (
    <div>
      subchild : {id}
      <Child />
    </div>
  );
}

export default function Component() {
  const id = useId();

  return (
    <div>
      <div>Home : {id}</div>
      <SubChild />
      <SubChild />
      <Child />
      <Child />
    </div>
  );
}

 

 

같은 컴포넌트임에도 인스턴스가 다르면 다른 랜덤한 값을 만들어내며 해당 값들이 모두 유니크 한 것을 아래 결과에서 확인해볼 수 있다.

 

 

 

 

이 id는 기본적으로 현재 트리에서 자신의 위치를 나타내는 32글자의 이진 문자열로 되어있다. R이면 서버에서 생성된 값, r이면 클라이언트에서 생성된 값이다. 자세한 알고리즘은 PR을 참고해보자.

 

https://github.com/facebook/react/pull/22644

 

useId by acdlite · Pull Request #22644 · facebook/react

Incremental hydration Empty nodes inside arrays Long sequences (> 32 bits) Add comments to explain the id generation algorithm In Fiber, find better way to get the number of children in the cur...

github.com

 

 

 

useTransition

 

UI변경을 가로막지 않고 상태를 업데이트할 수 있는 훅이다. 이를 활용하면 상태 업데이트를 긴급하지 않은 것으로 간주하여 무거운 렌더링 작업을 조금 미루는 것이 가능하다.

 

아래 예제처럼 Posts라는 컴포넌트안에 굉장히 느린 작업이 있어서 렌더링하는데 많은 시간이 걸린다고 생각해보자.

 

import { useState } from "react";

type Tab = "about" | "posts" | "contact";

export default function Component() {
  const [tab, setTabe] = useState<Tab>("about");

  function selectTab(nextTab: Tab) {
    setTabe(nextTab);
  }

  return (
    <>
      <TabButton isActive={tab === "about"} onClick={() => selectTab("about")}>
        Home
      </TabButton>
      <TabButton isActive={tab === "posts"} onClick={() => selectTab("posts")}>
        Posts
      </TabButton>
      <TabButton
        isActive={tab === "contact"}
        onClick={() => selectTab("contact")}
      >
        Contact
      </TabButton>
      <hr />
      {/* 일반적인 컴포넌트 */}
      {tab === "about" && <About />}
      {/* 매우 무거운 연산이 포함된 컴포넌트 */}
      {tab === "posts" && <Post />}
      {/* 일반적인 컴포넌트 */}
      {tab === "contact" && <Contact />}
    </>
  );
}


//PostsTab.tsx
import { memo } from "react";

const PostsTab = memo(function PostsTab() {
  const items = Array.from({ length: 1500 }).map((_, i) => (
    <SlowPost key={i} index={i} />
  ));

  return <ul className="items">{items}</ul>;
});

function SlowPost({ index }: { index: number }) {
  let startTime = performance.now();
  //렌더링이 느려지는 상황 가정
  while (performance.now() - startTime < 1) {}
  return <li className="item">Post#{index + 1}</li>;
}

 

 

위 코드를 실행하고 Post를 선택하자마자 Contact를 선택한다면 Post를 렌더링하기 위해서 브라우저가 잠깐 멈칫하고 Post의 렌더링이 끝난 이후에 Contact가 렌더링되게 된다.

 

Post렌더링 작업의 시간이 많이 걸려서 UI렌더링을 가로막기 때문이다. 만약 유저 시나리오가 Post를 누른게 실수여서 Contact를 바로 누른것이라면 Post의 렌더링 작업을 중간에 막는 것이 더 좋을 것이다.

 

이처럼 상태변경으로 인한 작업이 무거운 경우 useTransition을 사용해봄직 하다.

 

import { useTransition } from "react";
//...

export default 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 />}
        </>
      )}
    </>
  );
}

 

 

 useTransitiond는 isPendingr과 startTransition이 담긴 배열을 반환한다.

 

isPending : 상태 업데이트가 진행중인지 확인할 수 있는 boolean

startTransition : 긴급하지 않은 상태업데이트로 간주할 set함수를 인자로 받는다.

 

이런 useTransition은 리액트 18 변경사항 핵심중 하나인 '동시성'을 다룰 수 있는 훅이다. 이전 리액트는 렌더링이 동기적으로 작동하여 느린 렌더링 작업이 있는 경우 애플리케이션 전체적으로 느려졌지만 이러한 동시성을 다루는 훅을 활용하여 더 나은 사용자 경험을 제공할 수 있게 해준다.

 

해당 훅을 사용하는 경우 주의할점이 몇가지 있다.

 

1. startTransition 내부는 반드시 setState와 같은 상태를 업데이트 하는 함수만 넣을 수 있다.

2. startTransition으로 넘겨주는 상태 업데이트는 다른 동기상태 업데이트로 인해 지연될 수 있다.

3. startTransition으로 넘겨주는 함수는 반드시 동기함수여야 한다.

 

 

useDeferredValue

리액트 컴포넌트 트리에서 리렌더링이 급하지 않은 부분을 지연할 수 있게 도와주는 훅이다. 특정시간 발생하는 이벤트를 하나로 인색하여 한번만 실행하게 해주는 디바운스와 비슷하지만 몇가지 장점이 있다.

 

디바운스는 고정된 지연 시간을 필요로 하지만 useDeferredValue는 고정된 지연시간 없이 첫 렌더링이 완료된 이후에 지연된 렌더링을 수행한다. 따라서 이 렌더링은 중단할수도 있고 사용자 인터렉션을 차단하지도 않는다.

 

예제를 보며 이해해보자.

 

import { ChangeEvent, useDeferredValue, useMemo, useState } from "react";

const MyComponent = () => {
  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, idx) => (
          <li key={idx}>{str}</li>
        ))}
      </ul>
    );
  }, [deferredText]);

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

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

/* STYLE */

export default MyComponent;

 

 

list를 생성하는 기준을 text가 아닌 deferredText로 설정한 것을 볼 수 있다.

 

즉 잦은 변경이 있는 text를 먼저 렌더링한 이후 여유가 있는 경우 deferredTextf를 활용하여 list를 생성하게 된다.

 

useTransitiond은 set 함수를, useDeferredValue는 state값 자체를 감싸서 사용하는 방식이 다르며 지연된 렌더링을 한다는 공통점이 있다. 즉 상황에 맞게 사용하면 된다.

 

 

useSyncExternalStore

일반적인 코드를 작성할때는 별로 사용할 일이 없는 훅이다. 이 훅의 기원은 리액트 17까지 존재했던 useSubscriptiond이다. 두 훅의 구현자체에 많은 차이가 있으며 useSubscription의 구현이 useSyncExternalStore로 대체되었다고 생각해도 좋다.

 

tearing(테어링)

tearing은 말 그대로 찢어진다라는 의미로 하나의 state값이 있는 경우에도 서로 다른 값으로 렌더링되는 현상을 말한다. 리액트 17까지는 거의 일어나지 않는 현상이었지만 18에 도입된 useTransition 등 동시성 훅에 의해 이슈가 발생할 수 있게 되었다.

 

 

 

 

위 그림을 분석해보자.

 

1. 첫번째 컴포넌트에서 외부 데이터 스토어 값이 파란색이라 파란색으로 업데이트가 되었다.

2. 나머지 컴포넌트들이 렌더링을 하는 중 외부 스토어 값이 바뀌어서 빨간색으로 렌더링되는 문제가 발생했다.

 

 

물론 리액트 내부에서 관리하는 state는 동시성훅들이 이에대한 처리를 해두었지만 리액트 외부에 값을 저장하는 상태 라이브러리, document.body , window.innerWidth와 같은 값들이 변경되면 컴포넌트가 찢어지는 현상이 발생할 수 있다.

 

import {useSyncExternalStore} from 'react'

//useSyncExternalStore(
// subscribe : (callback) => Unsubscribe //콜백함수를 받아 스토어에 등록
// getSnapshot : () => state //컴포넌트에 필요한 현재 스토어의 데이터를 반환,
				//스토에서 값이 변경된것을 비교하여 컴포넌트를 리렌더링함
// ) => State

 

이제 실제 예시를 보자.

 

import { useSyncExternalStore } from "react";

function subscribe(callback: (this: Window, ev: UIEvent) => void) {
  window.addEventListener("resize", callback);
  return () => {
    window.removeEventListener("resize", callback);
  };
}

const MyComponent = () => {
  const windowSize = useSyncExternalStore(
    subscribe,
    () => window.innerWidth,
    () => 0 // 서버사이드 렌더링 시 제공되는 기본값
  );
  return <div></div>;
};

export default MyComponent;

 

 

위 예제에서 useSyncExternalStore는 subscribe함수의 첫번째 인수인 콜백을 추가하여 resize이벤트가 발생할때마다 콜백히 실행되게끔 했다. 두번째 인수로는 현재 스토어의 값을 넣어준다.

서버사이드에서는 해당값 추적이 안되므로 0을 세번째 인수로 넣어줬다.

 

이를 아래처럼 훅으로 만드는 것 또한 가능하다.

 

import { useSyncExternalStore } from "react";

function subscribe(callback: (this: Window, ev: UIEvent) => void) {
  window.addEventListener("resize", callback);
  return () => {
    window.removeEventListener("resize", callback);
  };
}

function useWindowWidth() {
  return useSyncExternalStore(
    subscribe,
    () => window.innerWidth,
    () => 0 // 서버사이드 렌더링 시 제공되는 기본값
  );
}

const MyComponent = () => {
  const windowSize = useWindowWidth();
  return <div>{windowSize}</div>;
};

export default MyComponent;

 

 

 

해당 훅을 useSyncExternalStore없이 사용했다 생각하고 만약 코드안에서 useTransition 등으로 동시성 처리를 해뒀다면 테어링 현상이 발생하게 되는데 이 문제를 해결할 수 있게 되었다.

 

 

useInsertionEffect

CSS-injs 라이브러리를 위한 훅이다. 

 

Next.js에 styled-componets를 적용할때 _document.tsx에 styled-components가 사용하는 스타일을 모두 모아 서버 사이드 렌더링 이전에 <style> 태그에 삽입하는 작업을 해야한다.

 

CSS 추가&수정은 브라우저 렌더링 과정 대부분을 다시 계산해야하는데 이는 리액트 관점에서는 사실 무거운 작업이다. 따라서 리액트 17까지는 이 작업이 클라이언트 렌더링 시에 발생하지 않게 서버사이드에서 스타일 코드를 삽입하였다.

 

이작업을 훅으로 할 수 있게 도와주는 게 이 useInsertionEffect이다.

 

해당훅의 기본적인 구조는 useEffect와 동일하다. 단 useInsertionEffect는 DOM이 실제로 변경되기 전에 동기적으로 실행된다.

 

functino Index(){
    useEffect(() =>{
    	console.log('useEffect!')
    })
    useLayoutEffect(() =>{
    	console.log('useLayoutEffect!')
    })
    useInsertionEffect(() =>{
    	console.log('useInsertionEffect!')
    })
}

 

 

useLayoutEffect과 useInsertionEffect모두 브라우저에 DOM이 렌더링 되기 전에 실행된다.

단 useLyaoutEffect는 DOM의 변경작업이 끝난 이후에, useInsertionEffect는 DOM 변경작업 이전에 실행된다는 차이점이 있다.

물론 해당훅도 라이브러리를 작성하는 경우가 아니라면 거의 작성할 일이 없으므로 실 코드에는 사용하지 않는것이 좋다.

 

728x90