Stack reconciler의 한계
React 16 이전 버전까지 사용되었던 stack reconciler에서는 state update로 인해 변경된 value를 적용한 virtual dom tree를 구축하고 구축한 virtual dom을 기반으로 실제 DOM에 적용하기까지의 과정이 동기적 ( synchronous )으로 이루어 졌다. 그렇기에 heavy한 component가 state update를 통해 re-render 될 때 main thread는 위의 과정이 모두 완료할 때 까지 다른 작업을 할 수 없게 되고 위의 과정이 종료될 때 까지는 user가 button을 click하거나 input에 값을 입력해도 화면이 freeze되어 user interaction에 즉각 반응하지 못하는 문제가 존재했다.
물론 성능이 좋은 device를 사용하거나 정말로 heavy한 component를 render하지 않는다면 위의 문제를 겪지 않았을 수도 있지만 web application을 사용하는 모든 유저가 좋은 성능을 가진 devide를 사용할 것이라 보장할 수 없고 제작하는 application에 따라 heavy한 component를 다뤄야 하는 경우는 얼마든지 있기에 이는 분명 기존 React architecture가 가진 한계점이였다.
Fiber reconciler의 도입
React는 위에서 언급된 한계점을 극복하고자 16 version부터 Fiber reconciler의 도입을 통해 여러 개선 사항을 선보이고 있다. 기존 Stack reconciler에서 문제가 될 수 있던 상황을 다시 한번 살펴보자. 조금 극단적인 예를 들어 input에 기입하는 keyword에 따라 length가 1000개가 가량이 되는 매우 긴 component list를 render하는 상황이라고 가정해보자. 성능이 좋은 device에서 테스트를 하면 크게 불편함을 못 느낄 수 있지만 개발자 도구의 performance tab에서 cpu throttle을 설정하고 테스트를 해보면 list render로 인해 input update event가 block되는 현상을 경험할 수 있다.
user가 모니터상 볼 수 있는 component list는 많아봐야 몇십 개 정도지만 main thread는 유저가 볼 수 없는 나머지 900개 이상의 component를 render하느라 user interaction인 input update event에 바로 반응하지 못한다. 즉, 보다 중요한 task가 보다 덜 중요한 task로 인해 실행되지 못하는 상황인 것이다.
하지만 Fiber reconciler의 도입으로 이제 React는 처리해야 하는 task를 우선순위를 통해 구분할 수 있게 되었고 어떤 task가 보다 덜 중요한지 React에게 알려줄 수 있는 방법을 제공한다. ( useTransition, useDeferredValue )
또한 Fiber reconciler의 도입을 통해 React는 rendering process를 fiber라는 보다 작은 작업 단위로 나누어 관리한다. 그리고 이를 통해 시간이 다소 소요되는 long task를 보다 작은 조각으로 나눠서 여러 frame에 걸쳐 처리할 수 있고 중간에 우선순위가 높은 task가 발생하면 해당 task를 중간에 우선 처리하는 것도 가능해졌다.
Render Lanes
React는 render lane이라는 개념을 통해 rendering process에 필요한 update에 대한 priority를 관리한다. 예를 들어 state update를 통해 re-render가 발생할 때 해당 update는 특정 lane에 분류되어 처리된다.
React가 처리해야 하는 task를 분류하기 위해 사용하는 lane은 각각 다른 priority level을 가지고 있으며 그 일부는 아래와 같다.
SyncLane : Hydration 과정 이후 특정 event가 발생하면 해당 event는 SyncLane으로 분류된다.
InputContinuousLane : hover나 scroll과 같이 지속적으로 발생하는 event는 InputContinuousLane으로 분류된다.
DefaultLane: Network request를 통한 update 또는 setTimeout과 같은 event는 DefaultLane으로 분류된다.
TransitionLane: startTransition 또는 useDeferredValue을 통해 호출된 event는 TransitionLane으로 분류된다.
예를 들어 setState를 통해 state가 update되면 이와 관련된 task는 SyncLane으로 분류되고 stateState가 startTransition안에서 호출되면 TransitionLane으로 분류된다. React는 이러한 render lanes을 통해 각 task를 priority따라 분류하고 priority가 높은 lane으로 분류된 task부터 처리한다.
useTransition
useTransition hook이 return하는 startTransition을 통해 특정 state update를 Transition으로 구분할 수 있다. Transition으로 구분된 state update는 TransitionLane으로 분류되어 처리된다. 만약 Transition으로 구분된 state update를 통해 graph component를 업데이트 하는데 도중에 user input과 같은 event가 발생한다면 React는 user input과 관련된 task를 우선 처리하고 graph component update와 관련된 task를 수행한다.
아래 예제를 살펴보자.
const [isPending, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [renderSearchKeyword, setRenderSearchKeyword] = useState("");
const handleSearcKeywordUpdate = () => {
startTransition(() => {
setRenderSearchKeyword(search);
});
}
const handleUpdate = (e: React.ChangeEvent<HTMLInputElement>) => {
const keyworkd = e.target.value;
setSearch(keyworkd)
};
return (
<div id="app">
<input type="text" value={name} onChange={handleUpdate} />
<button onClick={handleSearcKeywordUpdate}>Search</button>
<LargeAmountSearchRenderList renderSearchKeyword={renderSearchKeyword} />
</div>
);
위의 예제에서 startTransition function에 anonymous function을 전달하고 anonymous function안에서 setRenderSearchKeyword를 통해 renderSearchKeyword state를 update하고 있다. 위의 예제에서 renderSearchKeyword state는 Transition으로 구분되기에 renderSearchKeyword을 통해 update되는 LargeAmountSearchRenderList의 render가 모두 끝나기 전에 user가 input에 새로운 value를 입력하면 input관 관련된 event과 task가 우선 처리된다.
이렇듯 Transition으로 분류된 task가 완료 되기 전에 다른 state update 혹은 UI render와 같은 작업으로 인해 Transition task가 완료되지 못하고 pending 상태인지 구분할 때 useTransition hook이 return하는 isPending value를 사용할 수 있다.
isPending 상태 변화를 보다 명확하게 확인하기 위해 개발자 도구의 performance tab에서 CPU throttling 수치를 설정하고 아래 component를 기준으로 테스트를 진행 해보자.
User.tsx
type Props = {
name: string;
};
const User = ({ name }: Props) => {
const nameLength = Array.from({ length: name.length }, (_, i) => i + 1);
return (
<div>
{`User - ${name.length}`}
<div>
{nameLength.map(() => {
return <button>User</button>;
})}
</div>
</div>
);
};
export default User;
App.tsx
import { useState, useTransition } from "react";
import "./App.css";
import User from "./components/User";
function App() {
const [name, setName] = useState("");
const [transitionName, setTransitionName] = useState("");
const [isPending, startTransition] = useTransition();
const handleUpdate = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
startTransition(() => {
setTransitionName(e.target.value);
});
};
console.log({
name: name.length,
transitionName: transitionName.length,
isPending,
});
return (
<div id="app">
<input type="text" value={name} onChange={handleUpdate} />
<User name={name} />
</div>
);
}
export default App;
위의 예제에서 User component는 input에 설정된 value의 length 만큼 새로운 button을 render한다. 그리고 App component에선 state가 변경될 때 마당name, transitionName의 length와 isPending 상태가 어떻게 변하는지 log를 통해 확인하고 있다.
위의 예제에서 input box를 클릭하고 keyword의 특정키를 멈추지 않고 계속 누르고 있으면서 새로운 value를 input에 추가하면 isPending의 상태가 대부분 true 상태인 것을 확인할 수 있을 것이다. 이와 같이 Transition task보다 priority가 높은 다른 task로 인해 Transition task 처리가 지연되고 있을 때 isPending의 value는 true 상태가 되며 위의 예제 기준으로 transitionName state value 역시 isPending state가 true일 때는 새로운 input value가 아닌 기존 input value를 유지하고 있다가 background에서 Transition task가 완료되면 함께 변경된다.
startTransition을 통해 실행될 함수를 parent component에서 prop으로 전달받아 실행해도 무관하다. 아래 예제를 통해 startTransition에서 실행될 state update 함수를 paremt component에서 전달하는 예제를 살펴보자.
TabPage.tsx
import { useState } from "react";
import TabButton from "./TabButton";
import ProfileTab from "./ProfileTab";
import BoardTab from "./BoardTab";
import CartTab from "./CartTab";
const TabPage = () => {
const [tab, setTab] = useState("profile");
return (
<>
<TabButton
isActive={tab === "profile"}
tabAction={() => {
console.log(" ::: click - profile ::: ");
setTab("profile");
}}
>
Profile
</TabButton>
<TabButton
isActive={tab === "board"}
tabAction={() => {
console.log(" ::: click - board ::: ");
setTab("board");
}}
>
Board (slow)
</TabButton>
<TabButton
isActive={tab === "cart"}
tabAction={() => {
console.log(" ::: click - cart ::: ");
setTab("cart");
}}
>
Cart
</TabButton>
<hr />
<div></div>
{tab === "profile" && <ProfileTab />}
{tab === "board" && <BoardTab />}
{tab === "cart" && <CartTab />}
</>
);
};
export default TabPage;
TabButton.tsx
import { ReactNode, useTransition } from "react";
type Props = {
tabAction: () => void;
children: ReactNode;
isActive: boolean;
};
const TabButton = ({ tabAction, children, isActive }: Props) => {
const [isPending, startTransition] = useTransition();
return (
<button
style={{ backgroundColor: isActive ? "skyblue" : "gray" }}
onClick={() => {
startTransition(() => {
tabAction();
});
}}
>
{children}
</button>
);
};
export default TabButton;
ProfileTab.tsx
const ProfileTab = () => {
return <p>Profile Tab</p>;
};
export default ProfileTab;
BoardTab.tsx
function SlowComponent({ index }: { index: number }) {
let startTime = performance.now();
while (performance.now() - startTime < 1) {}
return <li className="item">Post #{index + 1}</li>;
}
const BoardTab = () => {
let items = [];
for (let i = 0; i < 600; i++) {
items.push(<SlowComponent key={i} index={i} />);
}
return <ul className="items">{items}</ul>;
};
export default BoardTab;
CartTab.tsx
const CartTab = () => {
return <div>CartTab</div>;
};
export default CartTab;
위의 코드는 현재 active 상태인 tab의 component를 render하는 예제다. BoardTab component는 component를 render에 시간이 다소 오래 소요되도록 추가적인 코드를 추가한 상태다. TabButton component는 parent로 부터 state를 update하는 function을 tabAction이라는 prop으로 전달 받아 startTransition 안에서 실행한다. startTransition 안에서 실행되는 function을 일반적으로 action이라고 부르므로 관례상 startTransition 안에서 실행될 setState function prop의 이름에 Action을 붙인다.
위의 예제에서 만약 parent에서 전달 받은 tabAction function을 startTransition 밖에서 호출하면 BoardTab component가 모두 render되는 동안 다른 button을 click해도 동작하지 않는다. application을 실행하는 main thread가 BoardTab component render를 처리하느라 user interaction에 반응하지 못하는 것이다.
하지만 위와 같이 state update를 startTransition안에서 실행시키면 BoardTab component가 render되는 중에도 다른 button을 click할 수 있고 click event도 정상적으로 작동하는 것을 확인할 수 있다. startTransition을 통해 해당 state update가 Transition으로 구분되고 다른 작업이 중간에 끼어 들 수 있는 상태가 되기 때문이다.
useTransition을 사용할 때 주의해야 할 사항은 다음과 같다.
startTransition을 통해 controlled input state를 update하지 않는다. startTransition을 통해 update 되는 state는 Transition으로 취급되어 asynchronous 방식으로 실행되지만 user interaction으로 발생하는 input update는 그 결과가 바로 반영되어야 하는 high priority task로 분류되기 때문이다. 예를 들어 다음 예제는 올바르지 못한 예제다.
import React, { useState, useTransition } from "react"; const InputPage = () => { const [transitionName, setTransitionName] = useState(""); const [isPending, startTransition] = useTransition(); const handleUpdate = (e: React.ChangeEvent<HTMLInputElement>) => { startTransition(() => { setTransitionName(e.target.value); }); }; return ( <div> <input type="text" value={transitionName} onChange={(e) => { handleUpdate(e); }} /> </div> ); }; export default InputPage;
만약 input 관련 value를 Transition을 통해 업데이트 해야 한다면 input value에 적용될 state와 Transition을 위한 state를 별도로 구성하여 사용한다.
import React, { useState, useTransition } from "react"; const InputPage = () => { const [name, setName] = useState(""); const [transitionName, setTransitionName] = useState(""); const [isPending, startTransition] = useTransition(); const handleUpdate = (e: React.ChangeEvent<HTMLInputElement>) => { setName(e.target.value); startTransition(() => { setTransitionName(e.target.value); }); }; return ( <div> <input type="text" value={name} onChange={(e) => { handleUpdate(e); }} /> <SomeComponent transitionName={transitionName} /> </div> ); }; export default InputPage;
startTransition을 통해 실행되는 함수는 synchronous 함수여야 한다.예를 들어 다음 예제에서 state update는 Transition으로 취급되지 않는다.
startTransition(() => { setTimeout(() => { setName('new name'); }, 1000); });
만약 setTimeout을 사용해야 한다면 다음과 같이 startTransition을 setTimeout의 callback을 통해 실행시켜준다.
setTimeout(() => { startTransition(() => { setName('new name'); }); }, 1000);
startTransition 안에서 await keyword를 사용하면 await keyword 다음과 실행되는 state update는 Transition으로 구분되지 않는다. 그렇기에 await keyword 다음 Transition으로 구분해야 하는 state update는 또 다른 startTransition을 통해 실행시켜준다.
startTransition(async () => { const data = await getUserData(); startTransition(() => { setUserData(data); }); });
useDefferedValue
useTransition은 startTransition을 통해 state를 update하는 function을 전달했다면 useDefferedValue는 특정 value를 직접 전달한다. 기본적인 형태는 다음과 같다.
import { useState, useDeferredValue } from 'react';
const SearchName = () => {
const [name, setName] = useState('');
const deferredName = useDeferredValue(name);
// ...
}
만약 위의 예제에서 name state에 update가 발생하면 deferredName과 연관된 update 작업은 TransitionLane으로 분류되어 처리된다. 그리고 startTransition과 마찬가지로 Transition 작업이 완료되기 전 priority가 더 높은 lane으로 분류되는 task가 존재하면 해당 task가 우선시 처리되고 deferredName value는 우선 update 되기 전 value를 그대로 사용하며 priority가 높은 task가 완료되면 background에서 새로운 value를 가진 deferredName과 연관된 update 작업이 처리되며 deferredName value 역시 완전히 업데이트 된다.
즉, startTransition과 마찬가지로 useDeferredValue가 return하는 value와 연관된 component render를 진행할 때 user가 input과 같은 event를 발생 시키면 background에서 새로운 component render tree를 모두 구성할 때 까지 user input event가 block 되는 것이 아닌 해당 input event를 우선 처리하고 다시 background에서 component render tree를 구성하여 적용된다.
useDeferredValue hook이 return하는 value를 사용하는 component의 render 과정이 input event에 의해 어떻게 영향을 받는지 다음 코드를 통해 살펴보자.
InputPage.tsx
import React, { useDeferredValue, useState, useTransition } from "react";
import InputChild from "./InputChild";
const InputPage = () => {
const [name, setName] = useState("");
const deferredName = useDeferredValue(name);
const handleUpdate = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
};
return (
<div>
<h3>InputPage</h3>
<input
type="text"
value={name}
onChange={(e) => {
handleUpdate(e);
}}
/>
<InputChild testName={deferredName} />
</div>
);
};
export default InputPage;
InputChild.tsx
import { memo } from "react";
type Props = {
testName: string;
};
const InputChild = ({ testName }: Props) => {
const nameLength = Array.from({ length: 1000 }, (_, i) => i + 1);
const hasInputValue = testName.length > 0;
return (
<div>
{`User - ${testName.length}`}
{hasInputValue && (
<div>
{nameLength.map((number) => {
return (
<button key={`button-${number}`}>
{testName} / {number}
</button>
);
})}
</div>
)}
</div>
);
};
export default memo(InputChild);
그리고 input event로 인한 Transition으로 분류되는 update가 어떻게 delay되는지 보다 확실하게 확인하기 위해 개발자 도구의 performance tab에서 CPU throttling 수치를 설정하고 테스트를 진행 해보자.
위의 코드에서 InputChild component는 1000개의 button을 render하고 button의 label text로 prop을 통해 전달 받은 deferredName value를 사용하고 있다. 이제 input에 focus를 두고 keyboard의 특정 key를 계속 눌러 지속적으로 input value를 update 시켜보면 input의 값을 update하는 event는 지속적으로 처리가 되고 deferredName value를 사용하는 InputChild button label text는 중간에 한 번씩만 업데이트 되거나 input update를 멈췄을 때 update되는 것을 확인할 수 있다.
위의 테스트를 통해 확인할 수 있듯이 background에서 새로운 value를 통해 render tree를 구성하는 도중 input event가 발생하여 input event를 처리하는 중에는 기존에 가지고 있던 deferredName가 component render에 우선 사용된다. 그러다 중간 중간 혹은 input update가 중단되면 최종 input value가 적용된 render tree가 구성되어 commit phase를 통해 실제 DOM에 update된다.
여기서 적용한 CPU throttle의 수치 또는 사용하는 pc의 성능에 따라 background에서 구성된 render tree가 실제 DOM에 적용될 때 화면이 잠시 freeze되는 현상을 볼 수 있을 것이다. 이는 background에서 변경된 value를 통해 새로운 render tree를 구성하는 작업은 asynchronous로 처리되어 중간에 다른 task가 개입이 가능하지만 commit phase를 통해 새로 구성된 render tree가 실제 DOM에 적용되는 과정은 synchronous로 처리되므로 reder tree가 실제 DOM에 적용될 때 까지 다른 task가 blocking될 수 있다.
useDeferredValue를 사용할 때 유의해야 할 사항은 다음과 같다.
만약 useDeferredValue에 전달하는 state가 startTransition을 통해 update된 state라면 useDeferredValue는 deferred value가 아닌 항상 new value를 return한다. 해당 state update는 Transition을 통해 이미 지연 처리가 적용 되었기 때문이다.
useDeferredValue hook에 전달하는 value는 primitive type이거나 object와 같은 renference type일 때 state로 관리되는 object, 혹은 cache가 적용된 object 혹은 component render 범위 밖에서 전달되는 object여야 한다. Object와 같은 reference type data는 component render마다 새로운 address 갖는 엄연히 다른 object가 되므로 component 내에 선언된 일반적인 object를 useDeferredValue hook에 전달할 경우 component가 render될 때 마가 해당 hook이 return하는 value를 사용하는 component와 관련하여 background에서 불필요한 component tree 구성이 수행될 수 있다.
useDeferredValue hook을 통해 deplay되는 background re-render 작업은 정해진 delay time이 존재하지 않는다. 즉, background re-render tree 구성 중 user event가 지속적으로 발생하면 background re-render tree 구성 역시 계속해서 deplay될 수 있다.
useDeferredValue를 통해 새로운 background re-render tree를 구성할 때는 useEffect와 같은 effect를 발생 시키지 않는다. useEffect와 같은 effect는 background re-render tree 구성이 완료되어 실제 DOM에 적용될 때 실행된다.
useDeferredValue는 background에서 수행되는 component re-render tree 구성 작업을 상황에 따라 delay 시켜주지만 network request까지 delay 시켜 불필요한 request를 방지해 주지는 않는다. 예를 들어 아래와 같이 prop이 변경될 때 마다 새로운 request를 보내는 react query function이 있다고 가정하면 input value가 변경될 때 마다 backend로 새로운 network request가 발생한다.
import { useSuspenseQuery } from "@tanstack/react-query"; import axios from "axios"; import { memo } from "react"; type Props = { testVal: string; }; const FetchChild = ({ testVal }: Props) => { const { data } = useSuspenseQuery({ queryKey: ["test", testVal], queryFn: async () => { const result = await axios.get(`http://localhost:4000/orders/${testVal}`); return result.data; }, }); const nameLength = Array.from({ length: 1000 }, (_, i) => i + 1); return ( <div> <h3>Fetch Child</h3> <h5>{String(data)}</h5> <div> {nameLength.map((number) => { return ( <button key={`button-${number}`}> {testVal} / {number} </button> ); })} </div> </div> ); }; export default memo(FetchChild);
위에서 언급 했듯이 useDeferredValue에 전달하는 value가 update 되면 useDeferredValue가 return하는 value를 사용하는 component는 우선 기존의 값을 그대로 사용해 render tree를 제공하고 background에서 새로운 value를 통한 component re-render tree 구축이 진행된다. 여기서 suspense의 사용 여부에 따라서도 useDeferredValue return하는 기존의 값이 최신 값으로 변하는 시기에 다소 차이가 발생할 수 있다. ( useTransition의 startTransition을 통해 update되는 state 역시 마찬가지다 )
다음 예제를 살펴보자.
FetchPage.tsx
import React, { Suspense, useDeferredValue, useState } from "react";
import FetchChild from "./FetchChild";
const FetchPage = () => {
const [testVal, setTestVal] = useState("a");
const deferredTestVal = useDeferredValue(testVal);
const handleUpdate = (e: React.ChangeEvent<HTMLInputElement>) => {
setTestVal(e.target.value);
};
console.log({ testVal, deferredTestVal });
return (
<div>
<h3>FetchPage</h3>
<input
type="text"
value={testVal}
onChange={(e) => {
handleUpdate(e);
}}
/>
<Suspense fallback={<div>loading...</div>}>
<FetchChild testVal={deferredTestVal} />
</Suspense>
</div>
);
};
export default FetchPage;
FetchChild.tsx
import { useSuspenseQuery } from "@tanstack/react-query";
import axios from "axios";
import { memo, Suspense } from "react";
import FetchGrandChild from "./FetchGrandChild";
type Props = {
testVal: string;
};
const FetchChild = ({ testVal }: Props) => {
const { data } = useSuspenseQuery({
queryKey: ["orderList", testVal],
queryFn: async () => {
const result = await axios.get(`http://localhost:4000/orders`);
return result.data;
},
});
return (
<div>
<h3>Fetch Child</h3>
<h5>{String(data)}</h5>
<Suspense fallback={<div>loading...</div>}>
<FetchGrandChild />
</Suspense>
</div>
);
};
export default memo(FetchChild);
FetchGrandChild.tsx
import { useSuspenseQuery } from "@tanstack/react-query";
import axios from "axios";
const FetchGrandChild = () => {
const { data } = useSuspenseQuery({
queryKey: ["orderList", "fetchGrandChild"],
queryFn: async () => {
const result = await axios.get(`http://localhost:4000/orders`);
return result.data;
},
});
return (
<div>
<h3>FetchGrandChild</h3>
</div>
);
};
export default FetchGrandChild;
위의 예제에서 FetchChild와 FetchGrandChild는 각각 response에 3초가 걸리는 endpoint에 useSuspenseQuery를 통해 request를 보내고 FetchChild와 FetchGrandChild component를 import해서 사용하는 부분을 모두 suspense로 wrapping해주고 있다.
위의 코드를 기준으로 우선 3초간 FetchChild component의 data가 fetch 되기를 기다렸다가 data가 fetch가 완료되면 이후에 input value를 계속해서 빠르게 업데이트 시키면서 input value를 가지고 있는 testVal state와 useDeferredValue가 return하는 deferredTestVal value가 어떻게 변하는지 log를 통해 확인해보자.
useSuspenseQuery hook이 return하는 value를 FetchChild component에서 전달 받아 useSuspenseQuery의 queryKey로 사용하고 있고 해당 useSuspenseQuery가 데이터를 요청하는 endpoint는 response까지 3초가 걸린다. 그렇기에 3초가 지나 data fetch가 완료되어 component render가 준비되기 전에 input에 새로운 value를 update하면 위의 log와 같이 제일 처음 가지고 있던 a라는 기존의 값을 우선 사용하고 background에서 새로운 value를 통해 component re-render 관련 작업이 수행된다.
하지만 위의 예제에서 제일 처음 페이지에 접근하고 data fetch에 필요한 3초 이전에 input을 지속적으로 업데이트 해보면 다음과 같이 deferredTestVal 역시 새로운 input value에 따라 함께 업데이트 되는 것을 확인할 수 있다. 이는 아마 input value가 업데이트 되었을 때 기존 값을 기준으로 default로 사용할 component의 tree가 ( Suspense가 wrapping하고 있는 )현재 시점에 존재하지 않으므로 useDeferredValue가 return하는 deferredTestVal 값 역시 최신 값으로 유지되는 것으로 예상된다.
다시 페이지를 refresh하고 3초가 지나기 전에 input을 abcd로 업데이트 시키고 3가 지난 후 data fetch가 완료 되고 다시 input을 업데이트 시켜보면 abcd가 기존 값으로 사용되는 것을 확인할 수 있다.
또한 위의 예제에서 볼 수 있듯이 useDeferredValue가 return하는 value를 꼭 element render에 사용해야 하는 것은 아니다.