Front-End(Web)/React - 프레임워크(React, Next)

[React] Concurrency(동시성) - #4. Transition API

ttaeng_99 2022. 11. 3. 04:45
반응형

지난 포스팅에서 React 18의 주요 컨셉인 Concurrent Rendering(동시성 렌더링) 을 정리하였다. (포스팅)

 

[React] Concurrency(동시성) - #3. Concurrent Rendering

🧐 서론 지난 포스팅에선, , 를 통한 Loading UI, 를 통한 Error UI 등에 대해 알아보았다. 하지만, 이런 로딩화면도 사용자에게 답답함을 줄 수 있기에, React는 좀 더 UX 친화적인 렌더링 방식을 제공하

abangpa1ace.tistory.com

 

이번 포스팅은 이를 실제로 구현하는 기능인 Transition API 들에 대해 정리하려고 한다.

Hooks 들의 사용법들을 확인할 뿐만 아니라, 이들을 통해 기존의 Suspense(로딩) 방식에서 UX적으로 어떻게 나아지는지 공감했으면 한다.

 


 

React 18에서 Concurrency 개념을 도입하면서, 이를 지원하기 위한 Hooks 들이 등장하였다.

이것이 useTransition(), useDeferredValue() 이며, 이 Hooks들은 상태(혹은 값)의 업데이트 우선순위를 낮추는 기능을 제공한다.

 

기존에 비동기나 디바운스/쓰로틀링으로 중요도가 낮은 업데이트를 지연시켰다면,

React 18부터는 Concurrency와 useTransition(), useDeferredValue() Hooks 들로 이를 대체할 수 있는 것이다.

* 본문을 읽기 앞서, 이전 포스팅에서 긴급(Urgent), 전환(Transition) 2개의 업데이트를 상기하고 가자.

 

 

💙 useTransition()

React 18 이전의 상태 업데이트는 모두 Urgent 였다. 즉, 모든 업데이트가 동등한 우선순위를 가지고 연산된 것이다.

 

useTransition연산부하가 큰 상태 업데이트의 우선순위를 낮추고, 이 업데이트의 진행여부를 노출하기 위해 사용된다.

유저 상호작용(타이핑, 클릭 등)에 관한 업데이트는 긴급, 이에 따른 데이터 갱신은 전환 업데이트로 하는 것이 대표적인 케이스일 것이다.

 

1. 기본문법

import { useState, useTransition } from 'react';

function Example() {
  const [first, setFisrt] = useState(1);
  const [second, setSecond] = useState(0);
  const [isPending, startTransition] = useTransition();		// 1) useTransition 문법선언
  
  const handleClick = () => {
    setFirst(first => first + 1);
    // 2) 전환 업데이트 함수를 startTransition() 으로 랩핑 
    startTransition(() => {
      setSecond(second => second + 1);
    })
  }
  
  return (
    <>
      <p>{first}</p>
      <p>{second}</p>
      // 3) 함수 실행 시, 전환 업데이트는 늦게 진행됨
      <button onClick={handleClick}>Up</button>
      // 4) 전환 업데이트가 연기되는동안 isPending은 true. 로더 등을 노출가능
      {isPending && <span>loading..</span>}
    </>
  )
}
  1. 먼저 useTransition() Hooks 문법을 선언한다.
    pending(전환 업데이트 진행여부 boolean), startTransition(전환 업데이트 실행 함수) 2개 프로퍼티를 배열로 제공한다.
  2. 먼저, startTransition() 메서드로 전환 업데이트를 할 setter 함수를 랩핑한다. 
  3. 클릭 등 이벤트로 함수가 실행되면 내부로직을 실행하고, 이 때 startTransition() 이 적용된 상태 업데이트는 지연된다.
  4. 그 동안 isPending 이 true로 전환된다. 이 값을 통해 로더 등을 조건부 렌더링하면 된다.

 

 

2. 전환 업데이트에 적용

import { useState, useMemo, useTransition } from 'react';

const fib = (num: number): number => {
  if (num <= 2) return 1;
  else return fib(num - 2) + fib(num - 1);
};

export default function ExampleTransition(): JSX.Element {
  const [num, setNum] = useState(1);
  const [fibNum, setFibNum] = useState(1);
  const [isPending, startTransition] = useTransition();
  const result = useMemo(() => fib(fibNum), [fibNum]);

  return (
    <div>
      <p>
        <button
          onClick={() => {
            setNum(i => i - 1);
            startTransition(() => setFibNum(i => i - 1));
          }}>
          -
        </button>
        <span>{num}</span>
        <button
          onClick={() => {
            setNum(i => i + 1);
            startTransition(() => setFibNum(i => i + 1));
          }}>
          +
        </button>
      </p>
      {isPending && <p>업데이트 중..</p>}
      <p>피보나치 수열의 {fibNum}번째 수는 {result}입니다.</p>
    </div>
  );
}
  1. useTransition 훅으로부터 [isPending, startTransition] 2개 프로퍼티를 선언한다.
  2. num과 fibNum 2개 상태값을 선언한다. num은 +,-로 숫자를 가감할 때 바로 보여야하므로 기본적인 긴급 업데이트로 지정한다.
  3. fibNum과 이를 통해 계산된 피보나치 수열값인 result 연산이 무거우므로 전환 업데이트 해야한다. (연산이 클릭을 방해하면 안됨)
    +,- 버튼 클릭 시, setFibNum() 세터함수를 startTransition() 메서드로 감싸준다.
  4. 전환 업데이트가 딜레이되는 동안 isPending은 true이므로, 이 경우 로더문구를 조건부로 노출해준다.

 

이처럼, useTransition 훅의 기본적인 용도는 상태값에 대한 긴급 업데이트와 전환 업데이트의 구분을 위함이다.

 

 

3. 이전 상태값의 유지

useTransition의 전환 업데이트는 다양한 활용이 가능하다. 아래처럼 Next 버튼을 누르면 컨텐츠가 바뀌는 예제가 있다고 가정하자.

(코드를 다 이해할 필요없다. Next 버튼으로 Name, Posts 정보를 API 콜로 갱신하며, 그동안 Suspense를 노출한다 정도만 짚고가자.)

 

import { Suspense, useState } from 'react';
import type { ProfileDataType } from '@/api/fetchProfileData';
import { fetchProfileData } from '@/api/fetchProfileData';

const initResource = fetchProfileData(0);

export default function SuspenseUX(): JSX.Element {
  const [resource, setResource] = useState(initResource);

  const handleClick = () => {
    const nextUserId = (resource.userId + 1) % 4;
    setResource(fetchProfileData(nextUserId));
  };

  return (
    <div>
      <h1>AS-IS: Suspense</h1>
      <button onClick={handleClick}>Next</button>
      <ProfilePage resource={resource} />
    </div>
  );
}

interface ResourceProps {
  resource: ProfileDataType;
}

function ProfilePage({ resource }: ResourceProps): JSX.Element {
  return (
    <Suspense fallback={<h2>Loading Profile...</h2>}>
      <ProfileName resource={resource} />
      <Suspense fallback={<h2>Loading Posts...</h2>}>
        <ProfilePosts resource={resource} />
      </Suspense>
    </Suspense>
  );
}

function ProfileName({ resource }: ResourceProps): JSX.Element {
  const user = resource.user.read();
  return <h1>{user?.name}</h1>;
}

function ProfilePosts({ resource }: ResourceProps): JSX.Element {
  const posts = resource.posts.read();
  return (
    <ul>
      {posts?.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

로딩 시인성은 좋지만, 경우에 따라 사용자는 로딩되는 동안 이전 컨텐츠를 계속해서 보고자하는 경우가 있을 것이다.

이러한 경우, 전환 업데이트를 통해 이전 상태값을 노출해주면서 로딩중임을 표현할 수 있다.

 

* 개선결과

/** useTransition 적용 */
export default function TransitionUX(): JSX.Element {
  const [resource, setResource] = useState(initResource);
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    startTransition(() => {
      const nextUserId = (resource.userId + 1) % 4;
      setResource(fetchProfileData(nextUserId));
    });
  };

  return (
    <div>
      <h1>TO-BE: useTransition</h1>
      <button disabled={isPending} onClick={handleClick}>Next</button>
      <span>{isPending ? 'Loading now...' : ''}</span>
      <ProfilePage resource={resource} isPending={isPending} />
    </div>
  );
}

interface ResourceProps {
  resource: ProfileDataType;
}

interface TransitionProps {
  isPending: boolean;
}

/** isPending(로딩중) 에 따라 CSS 분기 */
function ProfilePage({ resource, isPending }: ResourceProps & TransitionProps): JSX.Element {
  return (
    <div className={isPending ? 'opacity-50' : ''}>
      <ProfileName resource={resource} />
      <ProfilePosts resource={resource} />
    </div>
  );
}

수정된 코드들만 보도록 하겠다

  1. resource 정보 업데이트를 useTransition의 startTransition() 메서드를 적용하여 전환 업데이트로 분리했다.
  2. 업데이트동안 isPending이 true이므로, 이 때 버튼을 disabled 하거나 로더를 노출하는 등의 처리를 추가한다.
  3. 바로 하위 컴포넌트인 <ProfilePage> 역시 isPending 값을 같이 Props로 받는다. 로딩중엔 컨텐츠의 투명도를 올려준다.

전환 업데이트가 상태값을 바로 갱신하지 않는 점, 그리고 useTransition은 로딩여부를 노출하는 장점들을 활용하여 업데이트가 끝나기 전까지 이전 상태값을 노출하는 데에도 활용될 수 있는 것이다!

 


 

💙 useDeferredValue()

useDeferredValue 역시 전환 업데이트를 구현하기 위해 사용되는 Hooks로 useTransition과 매우 비슷하게 사용된다.

차이점은 useTransition은 state의 세터함수의 업데이트를 지연시켰다면,

useDeferredValue는 값(Props, computed value 등) 의 업데이트를 지연시킨다는 차이점이 있다.

 

1. 기본문법

import { useDeferredValue } from 'react';

function List({ results }) {
  const deferredResults = useDeferredValue(results);

  return (
    <ul>
      {deferredProducts.map((product) => (
        <li>{product}</li>
      ))}
    </ul>
  );
}
  1. useDeferredValue 훅을 가져온다. 그리고, 상태값(혹은 이에 기반한 값) 을 감싼 뒤 변수에 할당해준다.
  2. 이 변수(deferredResults)는 전환 업데이트가 적용되는 값이 된다.

 

 

2. 무거운 연산값에 활용

아래는 input을 입력할 때 마다 2500개의 무작위 숫자(0~99)를 계속해서 리렌더링하는 예제이다. (boxes는 숫자 엘리먼트들을 렌더링하는 useMemo)

영상에서는 input을 계속해서 입력하고 있지만, boxes의 연산이 무거운 만큼 입력이 중간중간 지연되는 것을 볼 수 있다.

import { useState, useMemo, Children } from 'react';

export default function NonDeferBox(): JSX.Element {
  const [input, setInput] = useState('');

  const boxes = useMemo(() => {
    return (
      <>
        <div>
          {Children.toArray(
            new Array(2500).fill(null).map(() => <span>{Math.floor(Math.random() * 100)}</span>),
          )}
        </div>
      </>
    );
  }, [input]);

  return (
    <div>
      <h2>AS-IS: Just update</h2>
      <input onChange={e => setInput(e.target.value)} />
      {boxes}
    </div>
  );
}

 

* 개선결과

import { useState, useMemo, Children, useDeferredValue } from 'react';

export default function DeferBox(): JSX.Element {
  const [input, setInput] = useState('');
  const deferredInput = useDeferredValue(input);	// 1) input에 대한 전환 업데이트 값 추가

  const boxes = useMemo(() => {
    return (
      <>
        {Children.toArray(
          new Array(2500).fill(null).map(() => <span>{Math.floor(Math.random() * 100)}</span>)
        )}
      </>
    );
  }, [deferredInput]);		// 2) 전환 업데이트 값을 useMemo 디펜던시 적용

  return (
    <div>
      <h2>TO-BE: Defer update</h2>
      <input onChange={e => setInput(e.target.value)} />
      {boxes}
    </div>
  );
}
  1. useDeferredValue 훅을 가져온 뒤, input을 감싸 이 상태값에 대한 전환 업데이트 값인 deferredInput을 만든다.
  2. boxes 렌더링을하던 useMemo()의 디펜던시를 input에서 deferredInput 으로 바꿔주면 전환 업데이트가 적용된다.

 


 

이번 포스팅에선 useTransition, useDeferredValue 2가지 훅은 전환 업데이트의 적용을 통해 더 나은 UX를 제공하는 예제들까지 보았다.

 

둘의 기능이 비슷한 만큼, 어떤 경우에 어느 훅을 사용하는게 나을지를 한 번 짚고 넘어가도록 하자!

  • useTransition상태값을 업데이트하는 코드(세터함수 등), useDeferredValue상태값과 연관된 값에 적용하는 것이 좀 더 적합
  • 상태 업데이트를 직접적으로 제어하고 있다면 useTransition, 서드파티 라이브러리 등으로 제공되는 값을 지연하려면 useDeferredValue 가 좀 더 적합
  • useTransitionisPending 이라는 전환 업데이트 지연 여부를 같이 제공하므로 이를 고지하기에 더 장점이 있음

 

Concurrency 시리즈는 이번 포스팅으로서 마무리되며, Suspense, Error Boundary의 React Query 적용 포스팅까지는 보는 것을 추천한다. (포스팅 링크)

 

📎 출처

- [React 18 Hooks] React 공식문서 : https://reactjs.org/docs/hooks-reference.html

- [React 17: Concurrent Mode(outdated)] React 공식문서 : https://17.reactjs.org/docs/concurrent-mode-reference.html#suspenselist

 

- [useTransition & useDeferredValue] betterprogramming tech blog : https://betterprogramming.pub/usetransition-vs-usedeferredvalue-3024f98e5443

- [useTransition & useDeferredValue] OpenReplay tech blog : https://blog.openreplay.com/usetransition-vs-usedeferredvalue-in-react-18/

- [useTransition & useDeferredValue] ktthee 님의 블로그 : https://velog.io/@ktthee/React-18-%EC%97%90-%EC%B6%94%EA%B0%80%EB%90%9C-useDeferredValue-%EB%A5%BC-%EC%8D%A8-%EB%B3%B4%EC%9E%90

반응형