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을 사용하면서 가장 주의해야 할 점이죠.
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 |
---|