728x90
SMALL

React는 매 업데이트마다 개발자의 생산성과 사용자 경험을 개선하기 위해 새로운 기능을 도입해왔습니다. 특히 React 18과 19에서는 비동기 작업 관리와 UI 반응성을 향상시키는 여러 Hook이 추가되었습니다. 이번 글에서는 useId, useTransition, 그리고 useOptimistic과 같은 최신 Hook을 살펴보고, 이를 통해 어떻게 효율적인 비동기 처리가 가능한지 탐구해 보겠습니다.

useId

useId는 React 18에서 고유한 ID 생성을 위해 도입된 Hook입니다. 서버와 클라이언트에서 동일한 ID를 생성하여 렌더링의 일관성을 보장하며, 클라이언트-서버 간 불일치를 방지할 수 있습니다.

아래는 useId를 사용하여 고유 ID를 생성하고, 폼의 labelinput을 연결하는 예제입니다.


    
import React, { useId } from 'react';
function Form() {
const id = useId();
return (
<div>
<label htmlFor={`${id}-username`}>Username</label>
<input id={`${id}-username`} type="text" />
<label htmlFor={`${id}-password`}>Password</label>
<input id={`${id}-password`} type="password" />
</div>
);
}
export default Form;

유의사항

컴포넌트가 여러 번 렌더링되더라도 ID의 일관성이 유지됩니다.
리스트의 key를 생성하기 위해 useId를 사용하지 말아야 합니다.

useTransition

useTransition은 사용자 인터페이스의 반응성을 유지하면서 비동기 상태 관리를 간편하게 처리할 수 있도록 제공되는 Hook입니다.


    
import React, { useState, useTransition } from 'react';
function UpdateName() {
const [name, setName] = useState('');
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
startTransition(() => {
// 비동기 작업 수행
setName('Updated Name');
});
};
return (
<div>
<p>{isPending ? 'Updating...' : name}</p>
<button onClick={handleSubmit}>Update Name</button>
</div>
);
}
export default UpdateName;

특징

  • 데이터 로딩이나 상태 업데이트 시 UI가 멈추지 않도록 보장합니다.
  • 이 진행되는 동안 사용자는 현재 상태를 확인할 수 있습니다.

예제 코드를 봤을 때, "useState로 처리하는 것과 어떤 것이 다르지? 🤔"라는 의문이 들 수 있습니다.

useState와의 비교

  1. useState로 상태 관리하기

    
import React, { useState } from 'react';
function UpdateNameWithState() {
const [name, setName] = useState('');
const [isPending, setIsPending] = useState(false);
const handleSubmit = () => {
setIsPending(true);
setTimeout(() => {
setName('Updated Name');
setIsPending(false);
}, 1000);
};
return (
<div>
<p>{isPending ? 'Updating...' : name}</p>
<button onClick={handleSubmit} disabled={isPending}>
Update Name
</button>
</div>
);
}
export default UpdateNameWithState;
  1. useTransition으로 상태 관리하기

    
import React, { useState, useTransition } from 'react';
function UpdateNameWithTransition() {
const [name, setName] = useState('');
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
startTransition(() => {
setTimeout(() => {
setName('Updated Name');
}, 1000);
});
};
return (
<div>
<p>{isPending ? 'Updating...' : name}</p>
<button onClick={handleSubmit} disabled={isPending}>
Update Name
</button>
</div>
);
}
export default UpdateNameWithTransition;

주요 차이점

특징 useState useTransition
로딩 상태 관리 isPending을 직접 관리해야 함 자동으로 isPending 관리
우선순위 처리 모든 업데이트가 동등한 우선순위 낮은 우선순위 작업을 비동기로 처리
UI 반응성 복잡한 작업 시 UI가 멈출 수 있음 높은 우선순위 작업을 차단하지 않음
렌더링 빈도 상태 변경마다 즉시 렌더링 발생 낮은 우선순위 작업을 배치 처리

참고 영상

useState vs useTransition 비교 영상

예를 들어, 대규모 리스트 필터링을 처리하는 검색 창을 생각해봅시다. 사용자가 검색어를 입력할 때마다 데이터를 필터링하고 렌더링해야 합니다.

  • useState를 사용하면 렌더링 과정에서 UI가 잠시 멈추거나 입력 반응이 느려질 수 있습니다.
  • 반면 useTransition은 필터링 작업을 낮은 우선순위로 처리하므로, 입력 창은 부드럽게 반응합니다.

테스트 코드

더보기

import React, { useEffect, useState, useTransition } from 'react';  

function PerformanceComparison() {  
  const \[inputValue, setInputValue\] = useState('');  
  const \[inputValue2, setInputValue2\] = useState('');  
  const \[listWithState, setListWithState\] = useState(\[\]);  
  const \[isPendingWithState, setIsPendingWithState\] = useState(false);  

  const \[listWithTransition, setListWithTransition\] = useState(\[\]);  
  const \[isPendingWithTransition, startTransition\] = useTransition();  

  const LIST\_SIZE = 4000;  

  // Independent animation (not re-rendered every time)  
  useEffect(() => {  
    const animate = () => {  
      const dots = document.getElementsByClassName('dot');  
      for (let i = 0; i < dots.length; i++) {  
        const dot = dots\[i\];  
        const currentTop = parseInt(dot.style.top || '0', 10);  
        dot.style.top = ((currentTop + 1) % 50) + 'px';  
      }  
      requestAnimationFrame(animate);  
    };  
    animate();  
  }, \[\]);  

  // Generate a large list with useState  
  const handleChangeWithState = e => {  
    const value = e.target.value;  
    setInputValue(value);  

    setIsPendingWithState(true);  
    setTimeout(() => {  
      const newList = \[\];  
      for (let i = 0; i < LIST\_SIZE; i++) {  
        newList.push(\`${value} - Item ${i + 1}\`);  
      }  
      setListWithState(newList);  
      setIsPendingWithState(false);  
    }, 0); // Simulate async work  
  };  

  // Generate a large list with useTransition  
  const handleChangeWithTransition = e => {  
    const value = e.target.value;  
    setInputValue2(value);  

    startTransition(() => {  
      const newList = \[\];  
      for (let i = 0; i < LIST\_SIZE; i++) {  
        newList.push(\`${value} - Item ${i + 1}\`);  
      }  
      setListWithTransition(newList);  
    });  
  };  

  return (  
    <div style={{ padding: '20px' }}>  
      <h1>useState vs useTransition Performance Comparison</h1>  

      <div>  
        <h2>Using useState (Blocking)</h2>  
        <input  
          type="text"  
          value={inputValue}  
          onChange={handleChangeWithState}  
          placeholder="Type something..."  
        />  
        <p>  
          {isPendingWithState  
            ? 'Updating...'  
            : \`Rendering ${listWithState.length} items...\`}  
        </p>  
        <div  
          style={{  
            position: 'relative',  
            height: '50px',  
            overflow: 'hidden',  
            margin: '10px 0',  
          }}  
        >  
          {Array.from({ length: 50 }).map((\_, index) => (  
            <div  
              key={index}  
              className="dot"  
              style={{  
                width: '5px',  
                height: '5px',  
                backgroundColor: 'blue',  
                position: 'absolute',  
                top: \`${Math.random() \* 50}px\`,  
                left: \`${index \* 10}px\`,  
                borderRadius: '50%',  
              }}  
            ></div>  
          ))}  
        </div>  
        <ul  
          style={{  
            height: '200px',  
            overflowY: 'scroll',  
            border: '1px solid #ddd',  
          }}  
        >  
          {listWithState.map((item, index) => (  
            <li key={index}>{item}</li>  
          ))}  
        </ul>  
      </div>  

      <div style={{ marginTop: '20px' }}>  
        <h2>Using useTransition (Non-blocking)</h2>  
        <input  
          type="text"  
          value={inputValue2}  
          onChange={handleChangeWithTransition}  
          placeholder="Type something..."  
        />  
        <p>  
          {isPendingWithTransition  
            ? 'Updating...'  
            : \`Rendering ${listWithTransition.length} items...\`}  
        </p>  
        <div  
          style={{  
            position: 'relative',  
            height: '50px',  
            overflow: 'hidden',  
            margin: '10px 0',  
          }}  
        >  
          {Array.from({ length: 50 }).map((\_, index) => (  
            <div  
              key={index}  
              className="dot"  
              style={{  
                width: '5px',  
                height: '5px',  
                backgroundColor: 'blue',  
                position: 'absolute',  
                top: \`${Math.random() \* 50}px\`,  
                left: \`${index \* 10}px\`,  
                borderRadius: '50%',  
              }}  
            ></div>  
          ))}  
        </div>  
        <ul  
          style={{  
            height: '200px',  
            overflowY: 'scroll',  
            border: '1px solid #ddd',  
          }}  
        >  
          {listWithTransition.map((item, index) => (  
            <li key={index}>{item}</li>  
          ))}  
        </ul>  
      </div>  
    </div>  
  );  
}  

export default PerformanceComparison;

useOptimistic

useOptimistic을 사용하면 비동기 작업 중 낙관적 UI 업데이트를 간편하게 구현할 수 있습니다.


    
import { useOptimistic, useRef, useState } from 'react';
async function deliverMessage(message) {
await new Promise(res => setTimeout(res, 1000));
return message;
}
function Thread({ messages, sendMessage }) {
const formRef = useRef();
async function formAction(formData) {
addOptimisticMessage(formData.get('message'));
formRef.current.reset();
await sendMessage(formData);
}
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(state, newMessage) => [
...state,
{
text: newMessage,
sending: true,
},
]
);
return (
<>
{optimisticMessages.map((message, index) => (
<div key={index}>
{message.text}
{!!message.sending && <small> (Sending...)</small>}
</div>
))}
<form action={formAction} ref={formRef}>
<input type="text" name="message" placeholder="Hello!" />
<button type="submit">Send</button>
</form>
</>
);
}
export default function App() {
const [messages, setMessages] = useState([
{ text: 'Hello there!', sending: false, key: 1 },
]);
async function sendMessage(formData) {
const sentMessage = await deliverMessage(formData.get('message'));
setMessages(messages => [...messages, { text: sentMessage }]);
}
return <Thread messages={messages} sendMessage={sendMessage} />;
}

특징

  • 서버 작업 완료 전, 즉각적인 피드백을 제공합니다.
  • 사용자 경험을 개선하고, 비동기 작업 중에도 끊김 없는 UI를 유지합니다.

참고 영상

useOptimistic 시연 영상

낙관적 상태 관리를 통해 서버 응답 시간을 기다리지 않고도 UI를 업데이트할 수 있어, 렌더링 지연 없이 빠르게 상태를 반영할 수 있었습니다.

결론

React의 최신 버전에서 제공하는 useTransition, useOptimistic와 같은 Hook은 비동기 작업 중에도 UI의 반응성을 유지하고, 사용자 경험을 한층 개선할 수 있는 강력한 도구들입니다.
물론, 이러한 Hook들은 특정 작업에서는 매우 유용하지만, 데이터 캐싱, 동기화, 오류 복구와 같은 서버 상태 관리를 포괄하는 react-query와 같은 라이브러리를 완전히 대체하기는 어려울 것같습니다.

끄적끄적 개발자