Hook - useTransition

2024. 6. 18. 18:49리액트 (React)/리액트 기술 (React Tech)


useTransition

 

 

useTransition은 UI를 블락킹(blocking)하지 않고 state를 업데이트하는 훅입니다.
쉽게 말해, set으로 state를 업데이트하고

이에 수반하여 리렌더링 할 때 UI가 멈추는 것을 방지합니다.

 

 

까지가 공식 문서의 설명이었는데,

사실 설명대로 훅을 잘 쓰려면 리액트가 어떤 환경에서 동작하는 지를 잘 이해해야 하고

어떤 상황에서 써야되는 지 잘 알아야 합니다.

 

 

 

 

 

 

 


startTransition

 

사실상 useTransition의 기능은 startTransition 함수와 비슷합니다.

그리고 startTransition 함수는 탑 레벨(top-level) 함수로써 굳이 훅을 쓰지 않고도 사용할 수 있습니다.

startTransition is very similar to useTransition, except that it does not provide the isPending flag to track whether a Transition is ongoing. You can call startTransition when useTransition is not available. For example, startTransition works outside components, such as from a data library.

 

설명을 위해 과일 이름 목록을 표현하는 예시 하나를 만들어보겠습니다.

(과일 이름 출처는 wikipedia - List of culinary fruits 입니다.)

export default function FruitLine({
    name,
    url,
}: {
    name: string;
    url: string | null;
}) {
    return url ? (
        <p>
            <a href={url} rel="noreferrer" target="_blank">
                {name}
            </a>
        </p>
    ) : (
        <p>{name}</p>
    );
}
import FruitLine from "./FruitLine";
import "./FruitList.scss";
import { Fruit } from "./model";

export default function FruitList({
    fruits,
}: {
    fruits: Fruit[];
}) {
    return (
        <ul className="list">
            {fruits.map((fruit) => (
                <FruitLine name={fruit.name} url={fruit.url} />
            ))}
        </ul>
    );
}
import { startTransition, useState } from "react";
import { useMount } from "../hooks/use-mount";
import "./FruitBox.scss";
import FruitList from "./FruitList";
import { Fruit } from "./model";

type Tab = "a-to-j" | "k-to-y" | "z-to-z";

export default function FruitBox() {
    const [tab, setTab] = useState<Tab>("a-to-j");
    const [aToJFruits, setAToJFruits] = useState<Fruit[]>([]);
    const [kToYFruits, setKToYFruits] = useState<Fruit[]>([]);
    const [zToZFruits, setZToZFruits] = useState<Fruit[]>([]);

    useMount(() => {
        import("./dataset/fruits.json").then((fruits) => {
            const sortedFruits = fruits.default.sort((a, b) => a.name.localeCompare(b.name));
            setAToJFruits(sortedFruits.filter((fruit) => /^[a-j]/i.test(fruit.name)));
            setKToYFruits(sortedFruits.filter((fruit) => /^[k-y]/i.test(fruit.name)));
            setZToZFruits(sortedFruits.filter((fruit) => /^[z-z]/i.test(fruit.name)));
        });
    });

    function handleClickTab(tab: Tab) {
        setTab(tab);
    }

    return (
        <div className="tech-use-transition">
            <div className="container">
                <div className="box">
                    <p className="title">Fruits</p>
                    {/* <div className="background" aria-hidden>
                        <img className="logo" src="logo192.png" />
                    </div> */}
                    <div className="tab">
                        <button
                            className={tab === "a-to-j" ? "active" : ""}
                            onClick={() => handleClickTab("a-to-j")}
                        >
                            A - J
                        </button>
                        <button
                            className={tab === "k-to-y" ? "active" : ""}
                            onClick={() => handleClickTab("k-to-y")}
                        >
                            K - Y
                        </button>
                        <button
                            className={tab === "z-to-z" ? "active" : ""}
                            onClick={() => handleClickTab("z-to-z")}
                        >
                            Z - Z
                        </button>
                    </div>
                    {tab === "a-to-j" && <FruitList fruits={aToJFruits} />}
                    {tab === "k-to-y" && <FruitList fruits={kToYFruits} />}
                    {tab === "z-to-z" && <FruitList fruits={zToZFruits} />}
                </div>
            </div>
        </div>
    );
}

 

 

다음과 같이 탭을 이동하여 알파벳 순으로 정렬된 과일 이름을 볼 수 있습니다.

 

이제 여기서 지연 시간을 좀 줘보겠습니다.

slowLevel이 라인 하나당 low면 5 밀리초, high면 1000 밀리초를 줍니다.

export default function FruitLine({
    ...
    delayMillisecond,
}: {
    ...
    delayMillisecond: number;
}) {
    const startTime = performance.now();
    while (performance.now() - startTime < delayMillisecond) {}

    return url ? (...);
}
export default function FruitList({
    fruits,
    slowLevel = "none",
}: {
    fruits: Fruit[];
    slowLevel?: "low" | "high" | "none";
}) {
    const delayMillisecond = (() => {
        switch (slowLevel) {
            case "high":
                return 1000;
            case "low":
                return 5;
            default:
                return 0;
        }
    })();
    
    return (
        <ul className="list">
            {fruits.map((fruit) => (
                <FruitLine name={fruit.name} url={fruit.url} delayMillisecond={delayMillisecond} />
            ))}
        </ul>
    );
}

 

 

이제 k-to-y 탭에 대해서 slow 만 적용해보면,

export default function FruitBox() {
    ...

    return (
        <div className="tech-use-transition">
            <div className="container">
                <div className="box">
                    ...
                    {tab === "a-to-j" && <FruitList fruits={aToJFruits} />}
                    {tab === "k-to-y" && <FruitList fruits={kToYFruits} slowLevel="low" />}
                    {tab === "z-to-z" && <FruitList fruits={zToZFruits} />}
                </div>
            </div>
        </div>
    );
}

 

 

K - Y 탭을 눌렀을 때 UI 요소들이 멈추는 걸 확인할 수 있습니다.

 

하지만 state를 바꾸는 set을 startTransition 함수로 감싸주면,

export default function FruitBox() {
    const [tab, setTab] = useState<Tab>("a-to-j");
    
    ...

    function handleClickTab(tab: Tab) {
        startTransition(() => {
            setTab(tab);
        });
    }

    return ( ... );
}

 

 

UI 요소들이 멈추지 않죠. 즉, K - Y 탭을 눌렀더라도 여전히 Z - Z, A - J 탭을 누를 수 있습니다.

그러나 한 가지 문제는, 다른 탭이 즉각적으로 렌더링되지 않습니다.

마치 K - Y 때 렌더링이 끝나야만 다음 렌더링이 시작하는 것으로 보이죠.

 

이제 useTransition을 사용해봅니다.

export default function FruitBox() {
    const [tab, setTab] = useState<Tab>("a-to-j");
    
    ...

    const [isTabPending, startTabTransition] = useTransition();

    function handleClickTab(tab: Tab) {
        startTabTransition(() => {
            setTab(tab);
        });
    }

    return ( ... );
}

 

결과를 통해 알 수 있듯, 다른 탭을 누르면 K - Y 탭을 눌렀을 때의 렌더링은 중단되고 다음 렌더링을 진행합니다.

즉, 즉각적으로 탭 전환이 가능해졌습니다.

 

 

 

 

 

 

 


Two startTransitions

해당 챕터는 필자가 코드를 나름 해석해서 내린 결론이므로 잘못된 점이 있을 수 있습니다. 

 

문서에서도 react.startTransition과 react.useTransition의 startTransition은 서로 동일한 역할을 가진다고 하는데,

어떻게 이런 차이가 발생하는 것일까요?

 

 

이건 훅의 교체 유무 때문입니다.

먼저 react.startTransition의 구현 부분을 살펴보죠.

// react/src/ReactStartTransition.js

export function startTransition(
  scope: () => void,
  options?: StartTransitionOptions,
) {
  ...

  if (enableAsyncActions) {
    ...
  } else {
    try {
      scope();
    } finally {
      warnAboutTransitionSubscriptions(prevTransition, currentTransition);
      ReactSharedInternals.T = prevTransition;
    }
  }
}

 

scope() 하나 있는 걸 보니, 우리가 startTransition에 주었던 함수가 끝까지 실행됩니다.

즉 setState(tab)을 통해 K - J 탭의 컴포넌트를 렌더링까지 끝날 때까지, 다음 탭의 렌더링은 순서를 기다려야 하죠.

따라서 화면 전환이 멈춘 것입니다.

 

물론 즉각적으로 탭을 누를 수 있고, 렌더링 이전에 계산도 바로 됩니다.

(잘 모르겠다면 console.log 같은 걸 추가해서 실행해봐도 좋습니다.)

하지만 (너무 빨라서 육안으로는 안 보일 수도 있지만) K - J 탭의 렌더링은 중간에 멈추지 않고 충실히 진행됩니다.

 

 

다음으로 useTransition의 startTransition 구현부를 보시죠.

// react-reconciler/src/ReactFiberHooks.js

function updateTransition(): [
  boolean,
  (callback: () => void, options?: StartTransitionOptions) => void,
] {
  const [booleanOrThenable] = updateState(false);
  const hook = updateWorkInProgressHook();
  const start = hook.memoizedState;
  const isPending =
    typeof booleanOrThenable === 'boolean'
      ? booleanOrThenable
      : // This will suspend until the async action scope has finished.
        useThenable(booleanOrThenable);
  return [isPending, start];
}

...

    useTransition(): [boolean, (() => void) => void] {
      currentHookNameInDev = 'useTransition';
      updateHookTypesDev();
      return updateTransition();
    },
    
...

 

핵심은 updateWorkInProgressHook() 함수입니다.

// react-reconciler/src/ReactFiberHooks.js

...

function updateWorkInProgressHook(): Hook {
  let nextCurrentHook: null | Hook;
  if (currentHook === null) {
    const current = currentlyRenderingFiber.alternate;
    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    nextCurrentHook = currentHook.next;
  }

  let nextWorkInProgressHook: null | Hook;
  if (workInProgressHook === null) {
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
  } else {
    nextWorkInProgressHook = workInProgressHook.next;
  }

  if (nextWorkInProgressHook !== null) {
    // There's already a work-in-progress. Reuse it.
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;

    currentHook = nextCurrentHook;
  } else {
    ...
  }
  return workInProgressHook;
}
    
...

 

다음 진행할 훅이 있으면 현재 진행중인 훅을 교체합니다.

useState('k-to-y')가 현재 진행중이더라도, useState('z-to-z') 또는 useState('a-to-j')로 교체될 수 있는 것이죠.

따라서 K - J 탭을 누르고 바로 다른 탭을 눌렀다면, K - J탭의 렌더링은 중단되고 다른 탭의 렌더링이 시작할 수 있는 것입니다.

 

 

 

 

 

 

 


Single Thread

 

리액트는 싱글 스레드(single thread) 환경에서 동작합니다.

쉽게 말해... 일을 하고 있는 사람이 한 명 뿐이라고 생각하면 됩니다.

그렇다면 한 명이기에 여러 개의 업무(tasks)가 들어왔을 때 어떻게 처리할까요?

만일 업무 중간에 파일을 읽거나, 네트워크 요청을 보내거나 해서 기다려야 한다면, 그 시간을 활용해서 다른 업무를 처리합니다.

한 명이 여러 가지 업무를 동시에(concurrently) 처리하는 것처럼 보이는 것이죠.

그러나 만일 한 개 업무를 계속 붙들고 있어야 한다면, 다른 업무를 못하겠죠?

바로 여기서 useTransition을 잘못 사용하는 상황이 생깁니다.

분명 해당 훅을 사용해서 state를 업데이트했는 데 UI가 블락되는 경우가 생기는 것이죠.

 

이전 과일 이름 목록 컴포넌트 중 FruitList에서 slowLevel을 지정할 수 있었는데,

여기서 Z - Z 탭에 high를 적용해 매우 느리게 만들어보겠습니다.

(극적인 연출을 위해 K - Y는 원상 복귀했습니다.)

export default function FruitBox() {
    ...

    const [isTabPending, startTabTransition] = useTransition();

    function handleClickTab(tab: Tab) {
        startTabTransition(() => {
            setTab(tab);
        });
    }

    return (
        <div className="tech-use-transition">
            <div className="container">
                <div className="box">
                    ...
                    {tab === "a-to-j" && <FruitList fruits={aToJFruits} />}
                    {tab === "k-to-y" && <FruitList fruits={kToYFruits} />}
                    {tab === "z-to-z" && <FruitList fruits={zToZFruits} slowLevel="high" />}
                </div>
            </div>
        </div>
    );
}

 

 

useTransition의 startTransition을 사용했음에도 아예 수 초 동안 UI가 먹통이되는 걸 확인해볼 수 있습니다.

Z - Z 탭의 과일은 겨우 2개 뿐인데 말이죠.

 

이는 우리가 지연시간을 준 로직을 보면 그 이유를 알 수 있습니다.

export default function FruitLine(...) {
    const startTime = performance.now();
    while (performance.now() - startTime < delayMillisecond) {}

    return url ? ( ... );
}

 

while 문은 싱글 스레드 환경에서 동작합니다.

한 명이 한 개 while 업무를 계속 붙들고 있는 상황인 것이죠.

저 코드가 진행되는 와중에는 이전에 설명했던 훅의 교체도 일어날 수 없습니다. 사실 어떤 로직도 간섭할 수 없죠.

 

따라서 useTransition의 효율을 잘 뽑아낼려면, 컴포넌트가 가벼워야 됩니다.

이것이 사실 useTransition을 사용하면서 가장 주의해야 할 점이죠.

자, 이제 useTransition을 잘 사용하려면 몇 번을 선택해야 할 지 알겠죠?

 

 

 

 

 

 

 

 


References

 

 

useTransition – React

The library for web and native user interfaces

react.dev

 

react/packages/react-reconciler/src/ReactFiberHooks.js at ddcecbbebf6af3fa32a323c0591dad1f63587aeb · facebook/react

The library for web and native user interfaces. Contribute to facebook/react development by creating an account on GitHub.

github.com

 

'리액트 (React) > 리액트 기술 (React Tech)' 카테고리의 다른 글

Hook - useDeferredValue, memo  (0) 2024.06.22