[React 19] 공식문서 톺아보기 - React API
• Study
리액트 api중에서 act, cache, lazy, startTransition, use 등에 대해서 공부하였습니다.
act
await act(async actFn)
await act(async actFn)
- React 컴포넌트의 테스트 환경에서 비동기 업데이트(상태 변경, 효과 실행 등)가 올바르게 처리되도록 보장하는 유틸리티 함수이다.
- 주로 Jest + React Testing Library 환경에서 컴포넌트 테스트시 사용된다.
테스트를 할때 act를 써야하는 이유
test('버튼 클릭 시 카운트 증가', () => {
render(<Counter />);
const button = screen.getByText('증가');
button.click(); // ❌ act() 없음
// 상태 업데이트가 완료되지 않았을 수 있음
expect(screen.getByText('1')).toBeInTheDocument(); // 실패 가능성
});
test('버튼 클릭 시 카운트 증가', () => {
render(<Counter />);
const button = screen.getByText('증가');
button.click(); // ❌ act() 없음
// 상태 업데이트가 완료되지 않았을 수 있음
expect(screen.getByText('1')).toBeInTheDocument(); // 실패 가능성
});
-
act()
가 없으므로, React가 실제로 DOM을 업데이트하기 전에 검증 코드인expect()
가 실행될 가능성이 있다.- 업데이트가 우연히 빠르게 완료되면 통과할 수 있지만 안정적이지 않다.
-
act()
는 테스트에서 React의 비동기 업데이트(상태변경, 렌더링 등)가 모두 완료될때까지 기다린뒤 검증할 수 있도록 하여 테스트의 안정성을 보장한다.
act를 직접사용하는 경우
- 기본적으로 RTL(React Testing Library)를 사용하게 되면 직접 쓸 필요가 없다.
- 하지만 저수준(React DOM Test Utils 등)으로 테스트할 때는 React가 상태 업데이트를 추적하지 못하므로 필요하다.
- React 17 이하에서는 자동 배치(Automatic Batching)가 없어
act()
로 수동 보정 필요하다. - 커스텀 테스트 환경 구축시 (테스트 라이브러리를 사용하지 않고), 테스트 라이브러리를 사용하지 않는 순수 환경에서 컴포넌트를 테스트할 때 필요하다.
cache
const cachedFn = cache(fn);
const cachedFn = cache(fn);
cache
를 통해 가져온 데이터나 연산의 결과를 캐싱한다. (데이터 요청에 한정짓지 않는다)- React 서버 컴포넌트와 함께 사용한다.
cache
는fn
의 캐싱된 버전을 반환하는데 이 과정에서fn
을 호출하지는 않는다.
같은 함수를 cache
로 여러번 호출하지 않기
import { cache } from 'react';
import { calculateWeekReport } from './report';
export default cache(calculateWeekReport);
import { cache } from 'react';
import { calculateWeekReport } from './report';
export default cache(calculateWeekReport);
cache
를 호출할 때마다 새 함수가 생성된다. 즉, 동일한 함수로cache
를 여러 번 호출하면 동일한 캐시를 공유하지 않는 다른 메모화된 함수가 반환된다.- 따라서 캐시함수를 선언할때는 컴포넌트 외부나 별도 파일에 선언하여 같은 메모화된 함수를 호출해야 한다.
// facebook/react/packages/react/src/ReactCacheClient.js
export const cache: typeof noopCache = disableClientCache
? noopCache
: cacheImpl;
// facebook/react/packages/react/src/ReactCacheClient.js
export const cache: typeof noopCache = disableClientCache
? noopCache
: cacheImpl;
cache
로 감싼 함수를 클라이언트에서 사용했을때는 특별히 캐시기능이 존재하지는 않는다.
캐시 매커니즘
// facebook/react/packages/react/src/ReactCacheImpl.js
type CacheNode<T> = {
s: 0 | 1 | 2; // 상태 (UNTERMINATED, TERMINATED, ERRORED)
v: T | mixed; // 캐시된 값 또는 에러
o: WeakMap<Function | Object, CacheNode<T>>; // 객체 캐시
p: Map<Primitive, CacheNode<T>>; // 원시값 캐시
};
// facebook/react/packages/react/src/ReactCacheImpl.js
type CacheNode<T> = {
s: 0 | 1 | 2; // 상태 (UNTERMINATED, TERMINATED, ERRORED)
v: T | mixed; // 캐시된 값 또는 에러
o: WeakMap<Function | Object, CacheNode<T>>; // 객체 캐시
p: Map<Primitive, CacheNode<T>>; // 원시값 캐시
};
cache
는 객체나 함수의 캐시, 원시값 캐시를 구분한다.- 리액트 오픈소스 코드에 의하면 캐시는 노드의 형태를 띄고있고 위와같은 구조를 가지고 있다.
for (let i = 0; i < arguments.length; i++) {
const arg = arguments[i];
if (typeof arg === 'function' || (typeof arg === 'object' && arg !== null)) {
// 객체/함수 → WeakMap으로 참조 비교
cacheNode = cacheNode.o?.get(arg) || createCacheNode();
cacheNode.o.set(arg, cacheNode);
} else {
// 원시값 → Map으로 값 비교
cacheNode = cacheNode.p?.get(arg) || createCacheNode();
cacheNode.p.set(arg, cacheNode);
}
}
for (let i = 0; i < arguments.length; i++) {
const arg = arguments[i];
if (typeof arg === 'function' || (typeof arg === 'object' && arg !== null)) {
// 객체/함수 → WeakMap으로 참조 비교
cacheNode = cacheNode.o?.get(arg) || createCacheNode();
cacheNode.o.set(arg, cacheNode);
} else {
// 원시값 → Map으로 값 비교
cacheNode = cacheNode.p?.get(arg) || createCacheNode();
cacheNode.p.set(arg, cacheNode);
}
}
- 인자로 객체가 들어오게 되면 위와 같은 코드로 캐시 데이터가 있는지 확인하고 없으면 새롭게 생성하게 된다.
React는 서버 요청마다 모든 메모화된 함수들을 위해 캐시를 무효화한다.
- 서버 요청이 발생할 때마다 (예: 페이지 새로고침, SSR 렌더링), React는 이전에 메모이제이션된 함수/값의 캐시를 초기화한다.
요청마다 캐시를 무효화하면 캐싱한 의미가 없지 않나?
서버캐시와 클라이언트 캐시의 차이를 알면 무효화하는게 당연하다.
서버 캐시와 클라이언트 캐시
- 클라이언트 캐시는 컴포넌트 생명주기 동안 유지되지만 서버캐시는 단일 http 요청동안 유지된다.
- 서버 컴포넌트로 한정짓는 이유도 단일 http 요청동안 유지되기 때문이다.
- 클라이언트 컴포넌트는 렌더링 시기가 다르고 요청 시기도 달라 캐시가 될 경우, 예전 데이터가 표시되는 등 여러 문제가 발생할 수 있다.
항목 | 서버 캐시 | 클라이언트 캐시 |
---|---|---|
위치 | 서버 (예: CDN, 서버 메모리, DB 결과 등) | 브라우저 메모리, localStorage, React의 상태 등 |
대상 | 모든 사용자에게 공통적으로 제공될 수 있음 | 사용자 개별적으로 저장됨 |
지속성 | 서버 설정에 따라 오래 유지되거나 무효화 정책이 있음 | 페이지 이동 시 사라지거나, 탭 닫으면 삭제됨 |
예시 | Next.js의 fetch(..., { cache: 'force-cache' }) , 서버에서 응답 저장 | React의 useMemo , 브라우저 캐시, useQuery 의 클라이언트 캐시 등 |
사용 목적 | 서버 부하 감소, 빠른 응답 제공 | 렌더링 최적화, 사용자 경험 향상 |
사전에 데이터 받아두기
const getUser = cache(async (id) => {
return await db.user.query(id);
});
function Page({ id }) {
getUser(id);
// ...
return <Profile id={id} />;
}
async function Profile({ id }) {
const user = await getUser(id);
// ...
}
const getUser = cache(async (id) => {
return await db.user.query(id);
});
function Page({ id }) {
getUser(id);
// ...
return <Profile id={id} />;
}
async function Profile({ id }) {
const user = await getUser(id);
// ...
}
- Page에서 실제 사용하지는 않지만 자식컴포넌트에서 사용하므로 미리 요청하여 데이터를 캐싱해둔다.
async function fetchData() {
return await fetch(`https://...`);
}
const getData = cache(fetchData);
async function MyComponent() {
getData();
// ... some computational work
await getData();
// ...
}
async function fetchData() {
return await fetch(`https://...`);
}
const getData = cache(fetchData);
async function MyComponent() {
getData();
// ... some computational work
await getData();
// ...
}
- 첫번째 호출은 기다리지 않지만 두번째 호출은 기다린다.
- 이 최적화는 데이터 불러오기를 기다리는 동안 React가 계산 작업을 계속할 수 있게 해 두 번째 호출에 대한 대기 시간을 줄일 수 있게 합니다.
createContext
const SomeContext = createContext(defaultValue);
const SomeContext = createContext(defaultValue);
createContext
를 사용하면 컴포넌트가 Context를 제공하거나 읽을 수 있다.
SomeContext.Provider
const [theme, setTheme] = useState('light');
// Provider
return (
<ThemeContext.Provider value={theme}>
<Page />
</ThemeContext.Provider>
);
// 이렇게도 사용이 가능하다.
return (
<ThemeContext value={theme}>
<Page />
</ThemeContext>
);
const [theme, setTheme] = useState('light');
// Provider
return (
<ThemeContext.Provider value={theme}>
<Page />
</ThemeContext.Provider>
);
// 이렇게도 사용이 가능하다.
return (
<ThemeContext value={theme}>
<Page />
</ThemeContext>
);
- 컴포넌트를
SomeContext.Provider
로 감싸서 이 컨텍스트의 값을 모든 내부 컴포넌트에 제공한다.
SomeContext.Consumer - 이전 방식
// ⚠️ 이전 방식 (권장하지 않음)
<ThemeContext.Consumer>
{(theme) => <button className={theme} />}
</ThemeContext.Consumer>
// ⚠️ 이전 방식 (권장하지 않음)
<ThemeContext.Consumer>
{(theme) => <button className={theme} />}
</ThemeContext.Consumer>
- 옛날 방식이긴하나 훅을 사용하지 않으니 서버컴포넌트에서도 사용이 가능하다.
- 서버컴포넌트에서만 context를 쓰고 리렌더링이 필요없다면 변수를 통해서 context를 쓰는게 가능은 하다.
- React가 감지할 수 없는 부분이기 때문에 서버컴포넌트에서 사용하는게 정상적이지는 않다.
useContext - 최근 방식
// ✅ 권장하는 방법
const theme = useContext(ThemeContext);
return <button className={theme} />;
// ✅ 권장하는 방법
const theme = useContext(ThemeContext);
return <button className={theme} />;
lazy
const SomeComponent = lazy(load);
const SomeComponent = lazy(load);
-
lazy
를 사용하면 컴포넌트가 처음 렌더링될 때까지 해당 컴포넌트의 코드를 로딩하는 것을 지연할 수 있다.- 여기서 지연로드는 늦게 로드된다는 의미보다도 메인 번들에서 분리되는 개념이다.
- 원래 일반적으로 선언된 컴포넌트는 사용여부와 관계없이 로드되지만
lazy
로 감싼경우는 렌더링이 시도되기 전까지는 아예 해당 JS 코드는 브라우저에 존재하지 않게 된다.
동작 예시
const LazyComponent = React.lazy(() => import('./LazyComponent'));
// ...
<div>
<button onClick={() => setShow(true)}>Show</button>
{show && (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent /> {/* 👈 이 시점에서야 비로소 로드 시작! */}
</Suspense>
)}
</div>;
const LazyComponent = React.lazy(() => import('./LazyComponent'));
// ...
<div>
<button onClick={() => setShow(true)}>Show</button>
{show && (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent /> {/* 👈 이 시점에서야 비로소 로드 시작! */}
</Suspense>
)}
</div>;
-
버튼을 클릭하면
LazyComponent
의 코드를 받아오기 시작한다. -
load는 Promise를 반환한다.
() => import('./MyComponent'); // → Promise<{ default: React.ComponentType }>
() => import('./MyComponent'); // → Promise<{ default: React.ComponentType }>
- React는 이 Promise가 resolve될 때까지 기다리면서 fallback ui를 보여준다.
<Suspense fallback={<div>Loading...</div>}>
<Suspense fallback={<div>Loading...</div>}>
- Promise가 해결되면, 해당 모듈의 default 속성을 가져온다.
- 따라서 컴포넌트가 export default로 내보낸 경우가 아니라면, React.lazy를 사용할 수 없다.
- 만약 Promise가 거부되면, React는 거부 이유를 가장 가까운 Error Boundary가 처리할 수 있도록
throw
한다.
컴포넌트 내부에 선언하지 말것
function Editor() {
// 🔴 잘못된 방법: 이렇게 하면 다시 렌더링할 때 모든 상태가 재설정됩니다.
const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));
// ...
}
// ✅ 올바른 방법: `lazy` 컴포넌트를 컴포넌트 외부에 선언합니다.
const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));
function Editor() {
// ...
}
function Editor() {
// 🔴 잘못된 방법: 이렇게 하면 다시 렌더링할 때 모든 상태가 재설정됩니다.
const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));
// ...
}
// ✅ 올바른 방법: `lazy` 컴포넌트를 컴포넌트 외부에 선언합니다.
const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));
function Editor() {
// ...
}
- 리렌더링이 발생할때마다 새로운
lazy
컴포넌트가 생성되며import()
가 재실행되고 동일한 청크 파일을 반복 요청된다. - 경로가 동적으로 변해야할 경우는
useMemo
로 최적화한다.const LazyComponent = useMemo( () => React.lazy(() => import(`./${componentPath}`)), [componentPath], // 경로 변경 시에만 재생성 );
const LazyComponent = useMemo( () => React.lazy(() => import(`./${componentPath}`)), [componentPath], // 경로 변경 시에만 재생성 );
next dynamic vs react lazy
- react lazy
Suspense
컴포넌트와 함께 사용해야 한다.- 클라이언트 사이드에서만 동작한다.
- next dynamic
- Next.js 전용 기능이다.
Suspense
대신loading
prop을 통해 로딩 상태 처리가 가능하다.- next dynamic의 ssr 옵션
{ ssr: true }
을 지원한다.- 서버에서 컴포넌트를 불러와서 SSR HTML에 포함시킨다.
- 클라이언트에서 별도 청크로 분리되어 초기 번들에는 포함되지 않는다.
memo
const MemoizedComponent = memo(SomeComponent, arePropsEqual?)
const MemoizedComponent = memo(SomeComponent, arePropsEqual?)
- 컴포넌트를
memo
로 감싸면 해당 컴포넌트의 메모된 버전을 얻을 수 있다. - 메모된 버전의 컴포넌트는 일반적으로 부모 컴포넌트가 리렌더링 되어도 Props가 변경되지 않았다면 리렌더링되지 않습니다.
arePropsEqual
: 컴포넌트의 이전 Props와 새로운 Props를 비교하는 로직을 커스텀하게 구성할 수 있는 함수로 일반적으로 이 함수를 지정하지 않는다.- React는 기본적으로
Object.is
로 각 Props를 비교한다.
- React는 기본적으로
memo를 추가해야하는 상황
- 대부분의 상호작용이 투박한 앱의 경우(페이지 또는 전체 섹션 교체 등) 일반적으로 메모이제이션은 불필요하다.
memo
로 최적화하는 것은 컴포넌트가 정확히 동일한 Props로 자주 리렌더링 되고, 리렌더링 로직이 비용이 많이 드는 경우에 유용하다.
주의사항
- 컴포넌트의 props가 이전과 동일하더라도 내부에서 사용중인 Context가 변경되면 컴포넌트는 리렌더링된다.
- 모든 props를 useMemo로 감싸지 않아도된다. primitive(원시) 값은 React.memo의 shallow 비교에서 "값 자체"로 비교되기 때문에 값이 같으면 메모화가 유지된다.
명시적으로 arePropsEqual
를 선언하기
const Chart = memo(function Chart({ dataPoints }) {
// ...
}, arePropsEqual);
function arePropsEqual(oldProps, newProps) {
return (
oldProps.dataPoints.length === newProps.dataPoints.length &&
oldProps.dataPoints.every((oldPoint, index) => {
const newPoint = newProps.dataPoints[index];
return oldPoint.x === newPoint.x && oldPoint.y === newPoint.y;
})
);
}
const Chart = memo(function Chart({ dataPoints }) {
// ...
}, arePropsEqual);
function arePropsEqual(oldProps, newProps) {
return (
oldProps.dataPoints.length === newProps.dataPoints.length &&
oldProps.dataPoints.every((oldPoint, index) => {
const newPoint = newProps.dataPoints[index];
return oldPoint.x === newPoint.x && oldPoint.y === newPoint.y;
})
);
}
- 드물지만 컴포넌트의 Props 변경을 최소화하는 것이 불가능할 수 있다. 이 경우
arePropsEqual
를 제공하여 맞춤화된 비교를 제공할 수 있다. arePropsEqual
의 로직을 실행하는것이 렌더링을 하는것보다 느리다면, 다시 렌더링을 하는게 더 좋을수 있다.
function Parent() {
const [count, setCount] = useState(0);
const handleClick = () => console.log(count); // ⚠️ 이 시점의 `count`를 기억!
return <Child onClick={handleClick} />;
}
function Parent() {
const [count, setCount] = useState(0);
const handleClick = () => console.log(count); // ⚠️ 이 시점의 `count`를 기억!
return <Child onClick={handleClick} />;
}
arePropsEqual
를 구현하는 경우 함수를 포함하여 모든 Prop를 비교해야 한다.- 위 예시에서
handleClick
의 참조는 변하지 않아서oldProps.onClick === newProps.onClick
로만 비교하게 되면 영원히true
로 반환하게된다. - 하지만
count
값이 변하더라도handleClick
의 참조는 변하지 않아서true
를 리턴하게 되므로 계속해서 이전count
를 리턴하는 버그로 이어지게 된다.
- 위 예시에서
startTransition
startTransition(action);
startTransition(action);
startTransition
는 UI의 일부를 백그라운드에서 렌더링할 수 있다.action
: 하나 이상의 state를 업데이트하는 함수를 포함한다.startTransition
에 전달하는 일반함수는 즉시 호출되고 모든 State 업데이트를 Transition으로 표시한다.- Transitions으로 표시된 상태 업데이트는 non-blocking 방식으로 처리되며, 불필요한 로딩 표시가 나타나지 않는다.
startTransition
만 사용하게 되면 Transition이 대기 중인지 알수 없다. 대기 중인 Transition을 표시하려면useTransition
를 통해isPending
를 사용한다.
"Transition으로 표시한다"의 뜻
- _상태 업데이트를 "급하지 않은(non-urgent) 작업"으로 분류하여, 더 중요한 업데이트가 먼저 처리될 수 있도록 우선순위를 조정하는 것을 의미한다.
- 기존 문제 : 복잡한 상태 업데이트가 메인 스레드를 블로킹하여 UI가 멈춘 듯한 느낌을 준다.
- 개선 :
startTransition
으로 감싼 업데이트는 "전환(Transition)"으로 분류되어 즉시 처리되지 않고 중간에 더 중요한 업데이트가 끼어들 수 있다. startTransition
내부의 상태 업데이트는 React의 "Transition" 큐에 등록된다.
Transition 큐
-
Concurrent Mode에서 사용되는 큐로 리액트 18버전 이후 도입되었다.
-
Transition Queue (트랜지션 큐), Default Queue (기본 큐), Urgent Queue (긴급 큐) 로 나뉘며 우선순위에 따라 업데이트를 진행한다.
- Urgent Queue →
flushSync
- Default Queue → 그 외
- Transition Queue →
startTransition
- Urgent Queue →
startTransition
은 "동기 실행"만 추적한다.
startTransition(() => {
setTimeout(() => {
setState('new value'); // ⚠️ 이 업데이트가 낮은 우선순위로 처리되길 기대
}, 1000);
});
startTransition(() => {
setTimeout(() => {
setState('new value'); // ⚠️ 이 업데이트가 낮은 우선순위로 처리되길 기대
}, 1000);
});
- 1초 뒤에 실행되는
setState('new value');
는 인식할 수 없는데 그 시점엔 이미startTransition
의 실행 컨텍스트가 끝났기 때문이다.
startTransition(async () => {
await fetchSomeData();
setState('data loaded'); // ⚠️
});
startTransition(async () => {
await fetchSomeData();
setState('data loaded'); // ⚠️
});
await
으로 비동기 흐름이 생기는 순간,setState('data loaded')
는 이미startTransition
의 영향력에서 벗어나서 Transition으로 간주하지 않는다.- setState가 논리적으로 startTransition 안에 있어도, 실제로 실행되는 순간이
startTransition
범위 밖이면, React는 트랜지션으로 간주하지 않는다.
use
const value = use(resource);
const value = use(resource);
use
는 Promise나 Context와 같은 데이터를 참조한다.use
는 Hook이 아니라서 다른 React Hook과 달리use
는if
와 같은 조건문과 반복문 내부에서 호출할 수 있다.- React 컴포넌트나 Hook 함수 내부에서 호출할 수 있으며 Hook 함수 외부나
try
-catch
블록에서use
를 호출할 수는 없다.- React는 use를 통해 비동기 데이터를 기다릴 때 "렌더링 경로"를 예측 가능하게 하려한다.
- 근데 try-catch 블록 안에 있으면 그 타이밍을 예측할 수 없어서 렌더링 타이밍이 깨질 수 있다.
use
에 전달된 Promise가 대기하는 동안use
를 호출하는 컴포넌트는 Suspend 된다.use
를 호출하는 컴포넌트가 Suspense 경계로 둘러싸여 있으면 Fallback이 표시된다.
사용예시
import { use } from 'react';
function Page() {
const dataPromise = fetchData(); // 바로 실행되지만 아직 안 기다림
const data = use(dataPromise); // 여기서 데이터 기다림
return <div>{data.title}</div>;
}
import { use } from 'react';
function Page() {
const dataPromise = fetchData(); // 바로 실행되지만 아직 안 기다림
const data = use(dataPromise); // 여기서 데이터 기다림
return <div>{data.title}</div>;
}
- 데이터 요청결과를 렌더링에 활용하고자 할때
dataPromise
를 직접쓰게 되면Promise<T>
의 타입을 가지고 있어 바로 참조가 불가능하다. - 이때 use를 쓰게 되면 값을 전부 받아올때까지 기다리므로
data.title
과 같이 참조가 가능하다.
서버에서 클라이언트로 데이터 스트리밍하기
import { fetchMessage } from './lib.js';
import { Message } from './message.js';
export default function App() {
const messagePromise = fetchMessage();
return (
<Suspense fallback={<p>waiting for message...</p>}>
<Message messagePromise={messagePromise} />
</Suspense>
);
}
import { fetchMessage } from './lib.js';
import { Message } from './message.js';
export default function App() {
const messagePromise = fetchMessage();
return (
<Suspense fallback={<p>waiting for message...</p>}>
<Message messagePromise={messagePromise} />
</Suspense>
);
}
// message.js
import { use } from 'react';
export function Message({ messagePromise }) {
const messageContent = use(messagePromise);
return <p>Here is the message: {messageContent}</p>;
}
// message.js
import { use } from 'react';
export function Message({ messagePromise }) {
const messageContent = use(messagePromise);
return <p>Here is the message: {messageContent}</p>;
}
- 클라이언트 컴포넌트에서 Promise를 생성하는 것보다 서버 컴포넌트에서 Promise를 생성하여 클라이언트 컴포넌트에 전달하는 것이 더 좋다.
- 클라이언트 컴포넌트에서 생성된 Promise는 렌더링할 때마다 다시 생성되어 매번 요청된다.
- 서버 컴포넌트에서 클라이언트 컴포넌트로 전달된 Promise는 리렌더링 시에도 동일한 참조를 유지하고 재요청하지 않는다.
- React는
use
에 전달된 Promise를 메모이제이션한다. 동일한 Promise가 다시 전달되면 이전에 리졸브된 값을 재사용한다.