안녕하세요, 단테입니다.
오늘은 리엑트18에서 추가된 useTransition 훅에 대해 알아보겠습니다.
지난 주 코어 팀 Dan abramov는 그의 트위터 계정에서 react 베타 문서에 useTransition이 업데이트 되었음을 홍보했는데요, 베타 문서를 기반으로 useTransition을 알아보겠습니다.
useTransition은 다음 처럼 선언해서 사용합니다.
const [isPending, startTransition] = useTransition()
파라메터로 아무것도 받지 않으며 useState와 동일하게 튜플 타입으로 두 아이템을 반환합니다.
isPending
은 startTransition이 적용된 상태 변경이 현재 pending 상태에 있는지 여부를 알려줍니다.startTransition
함수는 상태 업데이트에 transition을 적용합니다.startTransition
함수는 다음 처럼 useTransition을 사용하지 않고도 직접 리엑트에서 임포트해 사용할 수 있는 api 입니다.
import { startTransition } from "react";
다음처럼 상태 업데이트를 수행하는 setSomething
함수를 인자로 받아들입니다.
const [name, setName] = useState("Edward");
function handleClick() {
startTransition(() => setName("Dante"));
};
리엑트는 startTransition에 전달되는 함수 fn
을 파라메터 없이 호출하며 상태 업데이트가 이뤄질 때까지 스케쥴된 상태를 추적합니다.
다음 예제에서 TabContainer
컴포넌트는 Suspense를 사용해 세 가지 탭을 감싸고 있는데요,
Posts 글자로 된 TabButton
을 누르면 tab
상태를 Posts
로 변경하고
export default function TabContainer() {
const [tab, setTab] = useState("about");
return (
<Suspense fallback={<h1>🌀 Loading...</h1>}>
...
<TabButton
// ...
/>
...
{tab === 'posts' && <PostsTab />}
...
</Suspense>
)
}
PostsTab 내부에서 렌더링 시 데이터를 호출하는 구조로 코드가 작성 되어있습니다.
function PostsTab() {
const posts = use(fetchData('/posts'));
return (
<ul className="items">
{posts.map(post =>
<Post key={post.id} title={post.title} />
)}
</ul>
);
}
현재 구조에서는 렌더링 시 api를 호출해야 하는 탭의 경우 캐시되어 있는 데이터가 없으면 항상 loading indicator를 보여주어야 합니다.
다음의 예제에서는 useTransition을 사용해 TabButton의 onClick handler를 startTransition으로 감싸 loading indicator의 표기를 생략했습니다.
export default function TabButton({ children, isActive, onClick }) {
const [isPending, startTransition] = useTransition();
if (isActive) {
return <b>{children}</b>
}
if (isPending) {
return <b className="pending">{children}</b>;
}
return (
<button onClick={() => {
startTransition(() => { // <--
onClick();
});
}}>
{children}
</button>
);
}
인풋창 입력이나 보툰 클릭과 같은 유저 인터렉션에 대해 웹이 즉각적으로 반응하지 않는다면 사용자는 나쁜 UX를 경험하게 됩니다.
다음 예시 앱에서 Posts (slow)
를 클릭 시 상태 업데이트 에 따른 렌더링이 완료될 때까지 다른 인터렉션을 할 수 없습니다.
임의로 렌더링을 느리게 만든 동작이 완전히 완료되기 까지 앱의 다른 코드가 실행이 되지 못합니다. blocking이 되기 때문인데요,
상태 값 tab
에 따라 렌더링 트리가 업데이트 되기 때문에 여기서 StartTransition을 적용해야 할 부분은 tab
을 업데이트 시키는 setTab
입니다.
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
startTransition을 적용하고 난 후 Posts (slow)
버튼을 눌렀을 때 웹이 어떻게 반응하는지 확인해보세요.
화면 업데이트는 마찬가지로 늦게 되지만, 유저가 Posts 탭이 완전히 렌더링 되기 전에 다른 클릭 이벤트를 발생시키는 것을 막지 않습니다.
앞서 봤었던 예제 코드의 selectTab
함수는 자식 컴포넌트에 다음처럼 함수를 전달 시킵니다.
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
return (
<>
<TabButton
isActive={tab === 'about'}
onClick={() => selectTab('about')}
>
About
</TabButton>
<TabButton
isActive={tab === 'posts'}
onClick={() => selectTab('posts')}
>
Posts (slow)
</TabButton>
...
상위 컴포넌트 렌더링에 transition을 적용시키기 위해 startTransition을 꼭 컴포넌트 계층의 상위 레벨에 사용하지 않아도 관계 없습니다.
예를 들어 특정 도메인 (유저 인터렉션에 따른 업데이트가 느리게 발생하는 컴포넌트들의 집합)에서 사용되는 모든 버튼들이 render blocking 요소라면, 버튼을 사용하는 모든 컴포넌트에서 일일이 transition을 적용시키지 않고
Button Wrapper 컴포넌트를 만들어 해당 컴포넌트에서 transition의 책임을 도맡게 하는 것도 한가지 방법이 될 수 있습니다.
export default function TabButton({ children, isActive, onClick }) {
const [isPending, startTransition] = useTransition();
if (isActive) {
return <b>{children}</b>
}
return (
<button onClick={() => {
startTransition(() => {
onClick();
});
}}>
{children}
</button>
);
}
const [text, setText] = useState('');
// ...
function handleChange(e) {
// ❌ Can't use transitions for controlled input state
startTransition(() => {
setText(e.target.value);
});
}
// ...
return <input value={text} onChange={handleChange} />;
input change event가 발생할 때마다 굉장히 많은 돔 엘리먼트들을 업데이트 시켜야 하거나 무거운 로직을 실행시킬 경우
인풋 창의 업데이트가 느리게 일어날 수 있습니다.
이를 방지하기 위해 input 엘리먼트의 onChange 이벤트 핸들러에 startTransition을 사용을 통해 문제 해결을 시도해볼 수 있겠지만 인풋이벤트는 synchronously하게 받아오기 때문에 해당 방법은 사용할 수 없습니다.
그렇다면 어떻게 해결할 수 있을까요?
인풋 상태 값을 그대로 가져다가 렌더링에 사용하게 하지 말고
렌더링 블로킹을 일으키는 로직에서 참조하는 상태 값은 별도로 분리해서 사용해보겠습니다.
다음의 예제들은 Naver Deview 2021 Inside React 동시성을 구현하는 기술
발표에서 가져왔습니다.
위의 예제코드에서 메인 스레드를 blocking하는 컴포넌트는 ColorList 컴포넌트입니다. text의 상태 값을 가지고 그대로 렌더링을 하는데 필요한 props인 length에 값을 전달하기 때문에 <input/>
엘리먼트와 <ColorList/>
컴포넌트는 text
라는 상태를 함께 공유합니다.
위의 코드에서 ColorList의 렌더링은 상태 값 text
를 통해서 이뤄지기 때문에 text
상태 업데이트에 startTransition을 적용시켜 cpu intensive한 작업의 우선순위를 뒤로 미루고 input 엘리먼트의 업데이트 부터 우선 순위를 높여 업데이트했습니다.
import { useDeferredValue, useMemo, useState } from "react";
const size = 200;
export const List = ({ text }) => {
const lists = useMemo(() => {
return Array.from({ length: text.length * size }, (_, i) => (
<div key={i}>{i + 1}</div>
));
}, [text]);
return (
<div>
<div>total length:{text.length * size}</div>
{lists}
</div>
);
};
export const App = () => {
const [text, setText] = useState("");
const deferredInput = useDeferredValue(text);
const onChange = (e) => {
setText(e.target.value);
};
return (
<div>
<input onChange={onChange} value={text} />
<List text={deferredInput} />
</div>
);
};
여기서 컴포넌트 App에 선언된 deferredInput
은 useDeferredValue를 통해 렌더링 우선순위가 낮은 컴포넌트가 참조할 derived state를 만듭니다.
List 컴포넌트는 인풋의 글자 수가 늘어남에 따라 글자 수의 200배에 해당하는 리스트 엘리먼트를 화면에 렌더링 하는데요,
해당 컴포넌트느 deferredInput을 사용하기 때문에 업데이트가 지연되고 백그라운드에서 새로 받은 값으로 다시 렌더링을 시도합니다.
오늘은 useTransition 훅을 이용해 렌더링 우선순위를 설정하는 방법에 대해 알아봤습니다. 웹이 활용되는 산업이 점점 많아지고 소화할 수 있는 성능의 한계선이 높아짐에 따라 대단히 cpu intensive한 요구사항 또한 실제 현업에서 주어질 수 있는데요, 이떄 리엑트의 concurrent feature를 잘 활용한다면 UX를 개선시킬 수 있는 지점이 많을 것입니다.
한번 베타문서를 확인해보시고, 제가 예전에 작성했었던 concurrent feature 포스팅도 읽어보시면 도움이 될 것 같습니다.
감사합니다.