[React 19] 공식문서 톺아보기 - React Hook (2)
목차
useInsertionEffect
useInsertionEffect
는 layout Effects 가 실행되기 전에 전체 요소를 DOM 에 주입 할 수 있다.
useInsertionEffect(setup, dependencies?)
useInsertionEffect(setup, dependencies?)
- 내부적으로 useEffect와 거의 동일하게 동작한다.
- setup 내부에서 상태 업데이트를 할 수 없다.
- setup가 실행되는 시점에 ref는 아직 연결되지 않은 상태이다.
- 모든 클린업 함수가 실행되고 그다음에 setup함수가 실행되는
useEffect
와 달리useInsertionEffect
는 Effect마다 cleanup → setup을 개별적으로 실행한다.useEffect
: A cleanup → B cleanup → A setup → B setupuseInsertionEffect
: A cleanup → A setup → B cleanup → B setup
CSS-in-JS 라이브러리에서 동적 스타일 주입하기
useInsertionEffect
의 주로 사용 목적은 css-in-js에서 동적 스타일을 주입하기 위해 사용된다.- 런타임에서
<style>
태그를 이용한 스타일 변경은 권장되지 않으며 필요한 경우useInsertionEffect
를 사용해야 한다. - 다른 훅과의 차이점 (레이아웃 변경 예시)
useEffect
의 경우는 컴포넌트가 렌더링된 후에 실행되기에 깜빡임이 발생한다.useLayoutEffect
의 경우는 커밋단계의 마지막에 실행되기에 레이아웃 계산은 끝난 후이다. 따라서 페인트시 레이아웃이 깜빡이며 수정된다.useInsertionEffect
는 커밋단계에 실행돼서 실제 DOM 변경전에 스타일이 미리 주입된다. 따라서 DOM 계산 시점에 변경된 레이아웃을 반영할 수 있다.
사용예시
function App() {
const [width, setWidth] = useState(0);
const divRef = useRef();
useLayoutEffect(() => {
// div의 너비를 측정 (스타일이 없어 잘못된 값)
setWidth(divRef.current.offsetWidth); // 예: 0 (오류)
}, []);
return (
<div ref={divRef} style={{ width: '100px' }}>
Width: {width}px {/* "Width: 0px" 출력 (버그) */}
</div>
);
function App() {
const [width, setWidth] = useState(0);
const divRef = useRef();
useLayoutEffect(() => {
// div의 너비를 측정 (스타일이 없어 잘못된 값)
setWidth(divRef.current.offsetWidth); // 예: 0 (오류)
}, []);
return (
<div ref={divRef} style={{ width: '100px' }}>
Width: {width}px {/* "Width: 0px" 출력 (버그) */}
</div>
);
useInsertionEffect(() => {
// 스타일을 먼저 주입 (커밋 단계)
document.head.appendChild(styleTag);
}, []);
useLayoutEffect(() => {
// 이제 정확한 너비 측정 가능
setWidth(divRef.current.offsetWidth); // 100px
}, []);
useInsertionEffect(() => {
// 스타일을 먼저 주입 (커밋 단계)
document.head.appendChild(styleTag);
}, []);
useLayoutEffect(() => {
// 이제 정확한 너비 측정 가능
setWidth(divRef.current.offsetWidth); // 100px
}, []);
- 먼저
useInsertionEffect
가 실행될때 스타일을 주입한다. useLayoutEffect
의 실행시점은 커밋의 마지막단계이므로 ref에도 접근이 가능하다. 앞선useInsertionEffect
를 통해 설정된 스타일을 측정할 수 있다.- 따라서
useInsertionEffect
는 화면에 그려지기전에 스타일을 동적으로 변경할때 사용하고useLayoutEffect
는 레이아웃 측정할때 사용한다.
useLayoutEffect
useLayoutEffect
는 브라우저가 화면을 다시 그리기 전에 실행되는 useEffect
이다.
useLayoutEffect(setup, dependencies?)
useLayoutEffect(setup, dependencies?)
사용예시
- ui 렌더링을 하기전에 레이아웃 측정이 선행되어야하는 경우
useEffect
를 사용하면 [렌더링 → 계산 → 반영(리렌더링)] 으로 불필요한 렌더링이 발생하게 된다. useLayoutEffect
가 실행될때는 커밋단계의 마지막이므로 ref에 접근이 가능하고 브라우저 환경이므로window
객체에 접근이 가능하다. (DOM 너비나 브라우저 너비 측정가능)
function Tooltip() {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0); // tooltipHeight : 0
useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height); // tooltipHeight : 실제높이
}, []);
}
function Tooltip() {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0); // tooltipHeight : 0
useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height); // tooltipHeight : 실제높이
}, []);
}
Tooltip
은 초기화된 값인tooltipHeight = 0
으로 렌더링된다.- React가 이 툴팁을 DOM에 배치하고
useLayoutEffect
안의 코드를 실행한다. useLayoutEffect
가 툴팁의 높이를 계산하고 바로 다시 렌더링시킨다.Tooltip
이 실제tooltipHeight
로 렌더링 된다.- React가 DOM에서 이를 업데이트하고 브라우저가 툴팁을 표시한다.
초기 렌더링 → DOM 업데이트 → useLayoutEffect → 리렌더링 → DOM 업데이트 → 페인트
초기 렌더링 → DOM 업데이트 → useLayoutEffect → 리렌더링 → DOM 업데이트 → 페인트
- 실제로 렌더링은 2번 발생하지만, 화면에는 한 번만 표시(페인트)된다.
SSR을 통해서 서버에서 html을 생성한 경우, 서버에서 측정하여 값을 넘겨줄수 없을까?
- _서버에서 생성된 html은 실제 브라우저 환경과 다른 제약을 가진다.
- 서버(Node.js)는
document
,window
,getBoundingClientRect()
같은 브라우저 전용 API를 사용할 수 없다. - 따라서 요소의 크기(
width
/height
), 위치(offsetTop
), 뷰포트 정보 등을 알 수 없다.
- 서버(Node.js)는
- SSR의 본질
- 서버에서는 정적 HTML을 생성하고, 동적 레이아웃은 클라이언트로 위임해야한다.
- 서버의 역할은 초기 HTML을 빠르게 제공하는 것이지 브라우저와 동일한 렌더링을 보장하는 것이 아니다.
useEffect가 아닌 useLayoutEffect를 써야할때
function Button() {
const [isActive, setIsActive] = useState(false);
useEffect(() => {
if (isActive) {
// 버튼 클릭 후 잠깐 "비활성 상태"가 보일 수 있음
calculateExpensiveLayout(); // 무거운 계산
setIsActive(false);
}
}, [isActive]);
return <button onClick={() => setIsActive(true)}>Click</button>;
}
function Button() {
const [isActive, setIsActive] = useState(false);
useEffect(() => {
if (isActive) {
// 버튼 클릭 후 잠깐 "비활성 상태"가 보일 수 있음
calculateExpensiveLayout(); // 무거운 계산
setIsActive(false);
}
}, [isActive]);
return <button onClick={() => setIsActive(true)}>Click</button>;
}
useLayoutEffect(() => {
if (isActive) {
calculateExpensiveLayout(); // 페인트 전에 계산 완료
setIsActive(false); // 상태 업데이트도 동기 처리
}
}, [isActive]);
useLayoutEffect(() => {
if (isActive) {
calculateExpensiveLayout(); // 페인트 전에 계산 완료
setIsActive(false); // 상태 업데이트도 동기 처리
}
}, [isActive]);
- 렌더링할때 컴포넌트의 좌우너비를 계산하여 그에 맞게 표시하거나, 화면에 그려지기 전에 처리해야할 경우
useLayoutEffect
를 사용한다. - 그려지기 전에 처리하기 때문에 오래걸리는 작업의 경우 화면 응답성을 저하시킨다. 따라서 DOM조작이 화면 그리기 전에 반드시 완료되어야 할때만 한정적으로 사용해야한다.
useMemo
useMemo
는 리렌더링 사이에 계산 결과를 캐싱할 수 있게 해주는 Hook
const cachedValue = useMemo(calculateValue, dependencies);
const cachedValue = useMemo(calculateValue, dependencies);
calculateValue
: 캐싱하려는 값을 계산하는 함수이다.- 순수해야 하며 인자를 받지 않고, 모든 타입의 값을 반환할 수 있어야 한다. React는 초기 렌더링 중에 함수를 호출한다.
- React는
dependencies
가 변경되지 않았을 때 동일한 값을 다시 반환한다. 그렇지 않다면calculateValue
를 호출하고 결과를 반환하며, 나중에 재사용할 수 있도록 저장한다.
dependencies
:calculateValue
코드 내에서 참조된 모든 반응형 값들의 목록이다.- 반응형 값에는 props, state와 컴포넌트 바디에 직접 선언된 모든 변수와 함수가 포함된다.
주의사항
- useMemo는 초기 렌더링을 더 빠르게 만들지는 않는다. 리렌더링시 불필요한 작업을 건너뛰는데 도움을 준다.
사용예시
- 사이트 내에서 상호작용이 세분화 된 경우 유용할 수 있다. (상호작용에 의해 페이지 전체가 바뀌는 경우에는 유용하지 않을 수 있다.)
- 입력하는 계산이 눈에 띄게 느리고 종속성이 거의 변경되지 않는 경우 유용할 수 있다.
- memo로 간싸진 컴포넌트에 prop으로 전달하는 경우 필요하다.
- 다른 훅의 종속성으로 이용할 경우, useEffect 내에서 사용될 경우 필요하다.
useOptimistic
useOptimistic
는 UI를 낙관적으로 업데이트할 수 있게 해주는 Hook
- 비동기 작업이 진행 중일 때 다른 상태를 보여줄 수 있게 해준다. 실제로 작업을 완료하는 데 시간이 걸리더라도 사용자에게 즉시 작업의 결과를 표시하기 위해 일반적으로 사용된다.
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
state
: 실제 값을 나타낸다.updateFn(currentState, optimisticValue)
: 현재 상태와 addOptimistic에 전달된 낙관적인 값을 인자로 받아 낙관적인 업데이트 결과를 리턴하는 함수이다.optimisticState
: 낙관적 업데이트가 반영된 임시상태로updateFn
의 결과가 반영된 결과이다.addOptimistic
: 낙관적 업데이트를 트리거하는 함수이다.
예시코드
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(currentComments, newComment) => [
...currentComments,
{ text: newComment, isPending: true }, // 낙관적 업데이트 임시 데이터
],
);
async function handleSubmit(formData) {
const newComment = formData.get('comment');
addOptimisticComment(newComment); // 1. 즉시 성공 UI 보여주기 (실제요청x)
await submitComment(newComment); // 2. 실제 API 요청 (실패 시 자동 롤백)
}
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(currentComments, newComment) => [
...currentComments,
{ text: newComment, isPending: true }, // 낙관적 업데이트 임시 데이터
],
);
async function handleSubmit(formData) {
const newComment = formData.get('comment');
addOptimisticComment(newComment); // 1. 즉시 성공 UI 보여주기 (실제요청x)
await submitComment(newComment); // 2. 실제 API 요청 (실패 시 자동 롤백)
}
주의사항
- 낙관적 업데이트는 대부분의 요청이 성공할 것이라는 가정하에 설계된다.
- 따라서 미리 보여주는 ui또한 성공한 상태의 ui인경우가 많다.
- 그렇기에 실패가능성이 높은 요청에 대해서는 ui가 자주 변하므로 부적합하다.
- 업데이트 요청에 실패하면 자동으로 롤백하는데 이것은 에러를 감지하는게 아니라 state(실제 값)와 optimisticState(임시 값)를 비교해 실제 값으로 동기화한다.
useReducer
useReducer
는 컴포넌트에 reducer를 추가하는 Hook
const [state, dispatch] = useReducer(reducer, initialArg, init?)
const [state, dispatch] = useReducer(reducer, initialArg, init?)
reducer
: state가 어떻게 업데이트 되는지 지정하는 리듀서 함수이다.initialArg
: 초기 state가 계산되는 값이다.init
: 초기 state를 반환하는 초기화 함수이다. 이 함수가 인수에 할당되지 않으면 초기 state는initialArg
로 설정된다. 할당되었다면 초기 state는init(initialArg)
를 호출한 결과가 할당된다.state
: 첫번째 렌더링에서의 state는init(initialArg)
또는initialArg
로 설정된다.dispatch
: state를 새로운 값으로 업데이트하고 리렌더링을 일으킨다.
사용예시
import { useReducer } from 'react';
// action을 통해 state를 변경한다.
function reducer(state, action) {
if (action.type === 'incremented_age') {
// 반환하는 값이 state의 최종 변경상태이다.
return {
age: state.age + 1,
};
}
throw Error('Unknown action.');
}
export default function Counter() {
const [state, dispatch] = useReducer(reducer, { age: 42 });
return (
<>
<button
onClick={() => {
// type을 포함한 추가적인 속성을 객체로 전달할 수 있다.
dispatch({ type: 'incremented_age' });
}}
>
Increment age
</button>
<p>Hello! You are {state.age}.</p>
</>
);
}
import { useReducer } from 'react';
// action을 통해 state를 변경한다.
function reducer(state, action) {
if (action.type === 'incremented_age') {
// 반환하는 값이 state의 최종 변경상태이다.
return {
age: state.age + 1,
};
}
throw Error('Unknown action.');
}
export default function Counter() {
const [state, dispatch] = useReducer(reducer, { age: 42 });
return (
<>
<button
onClick={() => {
// type을 포함한 추가적인 속성을 객체로 전달할 수 있다.
dispatch({ type: 'incremented_age' });
}}
>
Increment age
</button>
<p>Hello! You are {state.age}.</p>
</>
);
}
- reducer 함수는 보통 action.type을 분기로하는 switch문을 사용한다.
초기값을 함수의 결과로 넣는 경우
const [state, dispatch] = useReducer(reducer, createInitialState(username));
const [state, dispatch] = useReducer(reducer, createInitialState(username));
const [state, dispatch] = useReducer(reducer, username, createInitialState);
const [state, dispatch] = useReducer(reducer, username, createInitialState);
useReducer
의 두번째 인자는 값으로 평가 되기 때문에 매번 함수가 실행된다. 따라서 함수의 결과가 초기값이 되는 경우는 세번째 인자에 넣어준다.
모든 훅의 인자는 렌더링마다 재평가된다.
useState(fn(initValue)); // ❌ 매번 실행된다.
useState(() => fn(initValue)); // ✅
useState(fn(initValue)); // ❌ 매번 실행된다.
useState(() => fn(initValue)); // ✅
useReducer
뿐 아니라 다른 훅에서도useState(fn(initValue))
와 같이 작성하면 렌더링마다 함수가 실행되므로useState(()=>fn(initValue))
로 작성해야한다.
dispatch으로 인한 값 변화는 다음 렌더링에 적용된다.
console.log(state.age); // 42
dispatch({ type: 'incremented_age' }); // Request a re-render with 43
console.log(state.age); // 42
console.log(state.age); // 42
dispatch({ type: 'incremented_age' }); // Request a re-render with 43
console.log(state.age); // 42
useState
와 동일한 매커니즘으로, 값 변화는 다음 렌더링에 적용된다.
reducer에서는 값을 직접수정하지 말고 반환해야한다
case 'changed': {
state.age++; // ❌ 직접 변경하면 안된다.
state.name = action.nextName;
return state;
}
case 'changed': {
return {
...state,
age: state.age + 1 // ✅ 새로운 객체로 반환해야한다.
name: action.nextName
};
}
case 'changed': {
state.age++; // ❌ 직접 변경하면 안된다.
state.name = action.nextName;
return state;
}
case 'changed': {
return {
...state,
age: state.age + 1 // ✅ 새로운 객체로 반환해야한다.
name: action.nextName
};
}
dispatch 이후 일부 속성이 undefined 가 되는 경우
case 'incremented_age': {
return {
...state,
age: state.age + 1
};
}
case 'incremented_age': {
return {
...state,
age: state.age + 1
};
}
- 기존 state에 대한 코드를 제외하고 변경된 부분만 넣게되면 변경된 속성만 포함된다.
useRef
useRef
는 렌더링에 필요하지 않은 값을 참조할 수 있는 Hook
const ref = useRef(initialValue);
const ref = useRef(initialValue);
initialValue
: ref 객체의current
프로퍼티 초기 값이다.ref
: current를 단일 프로퍼티로 가진 객체를 반환한다.
특징
-
ref.current
프로퍼티를 변경해도 React는 컴포넌트를 다시 렌더링하지 않는다. ref는 일반 JavaScript 객체이기 때문에 React는 변경을 알 수 없다. -
초기화를 제외하고는 렌더링 중에
ref.current
를 읽거나 쓰게되면 컴포넌트의 동작 예측이 어려워진다.function Component() { const ref = useRef(0); ref.current = Math.random(); // ❌ 렌더링 중 ref.current 수정 const [state, setState] = useState(ref.current); // ❌ ref.current로 상태 변경 return <div>{ref.current}</div>; }
function Component() { const ref = useRef(0); ref.current = Math.random(); // ❌ 렌더링 중 ref.current 수정 const [state, setState] = useState(ref.current); // ❌ ref.current로 상태 변경 return <div>{ref.current}</div>; }
function Component() { const ref = useRef(null); if (ref.current === null) { ref.current = new HeavyObject(); // ✅ 초기화시 ref 변경 (한번만 실행) } useEffect(() => { console.log(ref.current); // ✅ 이벤트/Effect 내부에서 ref 접근 }, []); return <div ref={ref} />; }
function Component() { const ref = useRef(null); if (ref.current === null) { ref.current = new HeavyObject(); // ✅ 초기화시 ref 변경 (한번만 실행) } useEffect(() => { console.log(ref.current); // ✅ 이벤트/Effect 내부에서 ref 접근 }, []); return <div ref={ref} />; }
-
렌더링할 때마다 재설정되는 일반 변수와 달리 리렌더링 사이에 정보를 저장할 수 있다.
-
리렌더링을 촉발하는 state 변수와 달리 변경해도 리렌더링을 발생시키지 않는다.
-
정보가 공유되는 외부 변수와 달리 각각의 컴포넌트에 로컬로 저장된다.
초기값 지정방법
function Video() {
const playerRef = useRef(new VideoPlayer());
// ...
function Video() {
const playerRef = useRef(new VideoPlayer());
// ...
- 모든 훅의 인자는 렌더링마다 재평가가 되기 때문에 초기값을 지정할때 함수의 실행을 넣을경우 값 자체는 초기에 한번만 사용되지만 함수 실행은 렌더링마다 발생한다.
// ✅ 방법1 처음 렌더링 도중 초기화
const playerRef = useRef(null);
if (playerRef.current === null) {
playerRef.current = new VideoPlayer();
}
// ✅ 방법2 값을 가져오는 함수안에서 초기화 로직 구현
function getPlayer() {
if (playerRef.current !== null) {
return playerRef.current;
}
const player = new VideoPlayer();
playerRef.current = player;
return player;
}
// ✅ 방법1 처음 렌더링 도중 초기화
const playerRef = useRef(null);
if (playerRef.current === null) {
playerRef.current = new VideoPlayer();
}
// ✅ 방법2 값을 가져오는 함수안에서 초기화 로직 구현
function getPlayer() {
if (playerRef.current !== null) {
return playerRef.current;
}
const player = new VideoPlayer();
playerRef.current = player;
return player;
}
useState
useState
는 컴포넌트에 state 변수를 추가할 수 있는 Hook
const [state, setState] = useState(initialState);
const [state, setState] = useState(initialState);
초기값을 지정할때 함수실행을 전달하지 않는다
function TodoList() {
const [todos, setTodos] = useState(createInitialTodos());
// ...
function TodoList() {
const [todos, setTodos] = useState(createInitialTodos);
// ...
function TodoList() {
const [todos, setTodos] = useState(()=>createInitialTodos(1));
// ...
function TodoList() {
const [todos, setTodos] = useState(createInitialTodos());
// ...
function TodoList() {
const [todos, setTodos] = useState(createInitialTodos);
// ...
function TodoList() {
const [todos, setTodos] = useState(()=>createInitialTodos(1));
// ...
- 훅의 모든 인자는 렌더링마다 재평가 되기 때문에 초기값을 계산하는 함수의 경우는 함수 자체를 전달한다.
- 이때 함수에 인자를 받아서 전달하는 경우는 익명함수를 이용한다.
업데이터 함수
setAge(age + 1); // setAge(42 => 43)
setAge(age + 1); // setAge(42 => 43)
setAge(age + 1); // setAge(42 => 43)
console.log(age); // 42
setAge(age + 1); // setAge(42 => 43)
setAge(age + 1); // setAge(42 => 43)
setAge(age + 1); // setAge(42 => 43)
console.log(age); // 42
- 동일한 렌더링에서 age는 42로 고정된다.
setAge((a) => a + 1); // setAge(42 => 43)
setAge((a) => a + 1); // setAge(43 => 44)
setAge((a) => a + 1); // setAge(44 => 45)
console.log(age); // 42
setAge((a) => a + 1); // setAge(42 => 43)
setAge((a) => a + 1); // setAge(43 => 44)
setAge((a) => a + 1); // setAge(44 => 45)
console.log(age); // 42
- 업데이터 함수를 사용하면 이전 업데이트 값을 반영하여 다음 렌더링에 적용하지만 여전히 동일한 렌더링에서는 42로 고정된다.
객체나 배열의 일부를 업데이트할때는 전개문법을 사용한다
setForm({
...form,
firstName: 'Taylor',
});
setForm({
...form,
firstName: 'Taylor',
});
key값은 컴포넌트의 state를 초기화 시킨다
export default function App() {
const [version, setVersion] = useState(0);
function handleReset() {
setVersion(version + 1);
}
return (
<>
<button onClick={handleReset}>Reset</button>
<Form key={version} />
</>
);
}
export default function App() {
const [version, setVersion] = useState(0);
function handleReset() {
setVersion(version + 1);
}
return (
<>
<button onClick={handleReset}>Reset</button>
<Form key={version} />
</>
);
}
- React에서 key는 컴포넌트를 식별하는데 중요한 역할을 한다. 컴포넌트의 위치가 변경되더라도 key는 React가 생명주기 내내 해당 컴포넌트를 식별할 수 있게 해준다.
- 반대로 key값이 바뀌게 되면 동일한 props의 컴포넌트라고해도 컴포넌트를 다시 생성한다.
- key를 변경하여 의도적으로 컴포넌트를 초기화하는데 사용할수 있다.
이전 상태를 이용해야하는 경우
const [prevCount, setPrevCount] = useState(count);
const [trend, setTrend] = useState(null);
if (prevCount !== count) {
setPrevCount(count);
setTrend(count > prevCount ? 'increasing' : 'decreasing');
}
const [prevCount, setPrevCount] = useState(count);
const [trend, setTrend] = useState(null);
if (prevCount !== count) {
setPrevCount(count);
setTrend(count > prevCount ? 'increasing' : 'decreasing');
}
공식문서에서는 잘 사용하지 않는 패턴이라고 표현하면서 소개해주고 있었는데, 얼마든지 우회할 수 있는 방법이 있을것같다. 예를들어
setCount
로 값을 변경하는 이벤트 핸들러에서 추세를 계산하는 방법도 있을것 같다.
- 값의 변화가 증가추세인지 감소추세인지를 저장해야할때,
useEffect
를 사용하지않고 렌더링 도중 계산한다. - 렌더링 도중 state가 변하게 되면 진행하던 렌더링을 중단하고 다시 렌더링을 하기 때문에
useEffect
를 이용해서 두번 렌더링하는것보다 효율적이라고 설명하고 있다. - 렌더링중에 실행되므로 반드시 prevCount !== count와 같은 조건문을 통해 무한 렌더링을 방지한다.
state에 함수를 저장하는 방법
// ❌ React는 someFunction를 초기화 함수로 여긴다
const [fn, setFn] = useState(someFunction);
function handleClick() {
setFn(someOtherFunction);
}
// ✅ 올바른 설정방법
const [fn, setFn] = useState(() => someFunction);
function handleClick() {
setFn(() => someOtherFunction);
}
// ❌ React는 someFunction를 초기화 함수로 여긴다
const [fn, setFn] = useState(someFunction);
function handleClick() {
setFn(someOtherFunction);
}
// ✅ 올바른 설정방법
const [fn, setFn] = useState(() => someFunction);
function handleClick() {
setFn(() => someOtherFunction);
}
useSyncExternalStore
useSyncExternalStore
는 외부 store를 구독할 수 있는 Hook
const snapshot = useSyncExternalStore(
subscribe, // 스토어 구독 함수
getSnapshot, // 현재 스토어 상태를 읽는 함수
getServerSnapshot? // (선택) SSR시 초기 상태 제공하는 함수
);
const snapshot = useSyncExternalStore(
subscribe, // 스토어 구독 함수
getSnapshot, // 현재 스토어 상태를 읽는 함수
getServerSnapshot? // (선택) SSR시 초기 상태 제공하는 함수
);
subscribe
: 하나의callback
인수를 받아 store를 구독한다.- store가 변경될 때, 제공된
callback
이 호출되어 React가getSnapshot
을 다시 호출하고 (필요한 경우) 컴포넌트를 다시 렌더링한다.subscribe
함수는 구독을 해제하는 함수를 반환해야 한다.
- store가 변경될 때, 제공된
getSnapshot
: store 데이터의 스냅샷을 반환한다.- 저장소가 변경되어 반환된 값이 다르면 React는 컴포넌트를 리렌더링한다.
getServerSnapshot
: 서버 사이드 렌더링(SSR) 시 초기 상태를 제공한다.
예시코드
function WindowWidth() {
const width = useSyncExternalStore(
(callback) => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
},
() => window.innerWidth, // 클라이언트 상태
() => 0, // SSR 기본값
);
return <div>창 너비: {width}px</div>;
}
function WindowWidth() {
const width = useSyncExternalStore(
(callback) => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
},
() => window.innerWidth, // 클라이언트 상태
() => 0, // SSR 기본값
);
return <div>창 너비: {width}px</div>;
}
- 브라우저 API 구독도 가능하다.
Transition 업데이트로 인한 불일치 문제
function Component() {
const data = useSyncExternalStore(store.subscribe, store.getSnapshot);
const handleSearch = () => {
startTransition(() => {
// Non-blocking 업데이트 시작
setSearchQuery(input); // Transition 업데이트
});
};
}
function Component() {
const data = useSyncExternalStore(store.subscribe, store.getSnapshot);
const handleSearch = () => {
startTransition(() => {
// Non-blocking 업데이트 시작
setSearchQuery(input); // Transition 업데이트
});
};
}
- 초기 렌더링: data = A (스토어 상태 A)
- Transition 업데이트 시작: React는 메인 스레드를 차단하지 않고 백그라운드에서 새 가상 DOM 생성
- 도중에 스토어 변경: 외부 스토어가 A → B로 변경됨 (store.subscribe가 알림)
- DOM 적용 직전: React는
getSnapshot
을 다시 호출 → B 반환 (이전과 다름) - 문제 발생: 렌더링 중 사용된 스토어 상태(A) ≠ 실제 스토어 상태(B) → UI 불일치 가능성
해결과정
- Transition 업데이트가 DOM을 적용하기 직전, React는
getSnapshot
을 다시 호출해 스토어 상태가 변경되었는지 확인한다.- 같은 값: Non-blocking 업데이트 계속 진행한다.
- 다른 값: 기존 업데이트를 취소하고 Blocking 모드로 우선순위를 높여 재시작하여 렌더링을 완료한다.
- Blocking 업데이트 수행
- React는 메인 스레드를 차단하고, 즉시 동기적으로 리렌더링한다.
- 모든 컴포넌트가 최신 스토어 상태(B)를 반영하도록 보장한다.
useSyncExternalStore
로 구독한 값으로 UI를 변경할 경우
function ShoppingApp() {
const selectedProductId = useSyncExternalStore(...); // 외부 스토어 구독
return selectedProductId != null ? <LazyProductDetailPage /> : <FeaturedProducts />;
}
function ShoppingApp() {
const selectedProductId = useSyncExternalStore(...); // 외부 스토어 구독
return selectedProductId != null ? <LazyProductDetailPage /> : <FeaturedProducts />;
}
- UI가
selectedProductId
에 종속적이다. 이런상황에서selectedProductId
는 외부 스토어의 값으로, 예상치 못하게 자주 값이 변경될 수 있고 사용자는 의도치 않게 로딩화면을 반복적으로 보게된다. - 따라서 구독중인 스토어의 값을 기반하여 UI를 변경하는 것은 신중해야한다.
서버렌더링 지원 getServerSnapshot
export function useOnlineStatus() {
const isOnline = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
return isOnline;
}
function getServerSnapshot() {
return true; // 서버에서 생성된 HTML에는 항상 "Online"을 표시한다.
}
export function useOnlineStatus() {
const isOnline = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
return isOnline;
}
function getServerSnapshot() {
return true; // 서버에서 생성된 HTML에는 항상 "Online"을 표시한다.
}
- 서버에서는 window 객체에 접근을 못하므로 서버에서 html을 생성할때 해당 값이 어떤값으로 동작할지에 대한 설정을 추가할 수 있다.
- HTML을 생성할 때 서버에서 실행된다.
- hydration 중 즉 React가 서버 HTML을 가져와서 인터랙티브하게 만들 때 클라이언트에서 실행된다.
객체를 반환하지 않는다
// ❌ getSnapshot에서 객체를 반환하면 안된다.
function getSnapshot() {
return {
todos: myStore.todos
};
}
// ✅ 불변 데이터를 반환할 수 있다.
function getSnapshot() {
return myStore.todos;
}
// ❌ getSnapshot에서 객체를 반환하면 안된다.
function getSnapshot() {
return {
todos: myStore.todos
};
}
// ✅ 불변 데이터를 반환할 수 있다.
function getSnapshot() {
return myStore.todos;
}
- React는
getSnapshot
반환 값이 지난번과 다르면 컴포넌트를 리렌더링한다. - 객체를 반환하게 될 경우 매번 다른 값으로 인식하여 리렌더링하게 되고 무한루프에 빠진다.
subscribe
를 훅 호출과 같은 레벨로 선언하지 않는다
function ChatIndicator() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
// ❌ 항상 다른 함수를 사용하므로 React는 렌더링할 때마다 다시 구독한다.
function subscribe() {
// ...
}
}
function ChatIndicator() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
// ❌ 항상 다른 함수를 사용하므로 React는 렌더링할 때마다 다시 구독한다.
function subscribe() {
// ...
}
}
- 리렌더링할때마다 재선언되고 다시 구독되어 매번 실행된다.
- 다른곳에 선언하거나
useCallback
으로 감싼다.
useTransition
useTransition
은 UI의 일부를 백그라운드에서 렌더링 할 수 있도록 해주는 Hook
const [isPending, startTransition] = useTransition();
const [isPending, startTransition] = useTransition();
isPending
: 대기 중인 Transition 이 있는지 알려준다.startTransition
: 상태 업데이트를 Transition 으로 표시할 수 있게 해준다.startTransition
내에서 호출되는 함수를 "Action"이라고 한다. 따라서startTransition
내부에서 호출되는 콜백의 이름은 action이거나 "Action" 접미사를 포함해야 한다.action
: 하나 이상의set
함수를 호출하여 일부 상태를 업데이트한다.
사용예시
function Router() {
const [page, setPage] = useState('/');
const [isPending, startTransition] = useTransition();
function navigate(url) {
startTransition(() => {
setPage(url);
});
}
function Router() {
const [page, setPage] = useState('/');
const [isPending, startTransition] = useTransition();
function navigate(url) {
startTransition(() => {
setPage(url);
});
}
- Transition으로 실행된 상태 업데이트는 non-blocking 방식으로 처리되며 로딩표시가 나타나지 않는다.
- 원치 않는 로딩 표시기를 방지하므로 사용자가 탐색 시 갑작스러운 이동을 방지할 수 있다.
useTransition
은 백그라운드에서 상태를 준비한 후 한 번에 렌더링한다.- non-blocking 방식으로 동작하므로 처리하는동안 사용자는 다른 행동이 가능하다.
- Transition으로 표시된 state 업데이트는 다른 state 업데이트에 의해 중단된다.
- 우선순위가 낮아 Transition이 아닌 일반 state 업데이트를 우선적으로 처리한다.
- 즉,
useTransition
는 백그라운드로 처리하여 로딩표시를 보여주고 싶지 않거나 우선순위가 낮은 업데이트를 처리하는데 사용한다.
탭 이동 예시 (A → B → C → A)
순서 | 동작 | 결과 |
---|---|---|
A → B | startTransition 으로 B 탭 렌더링 시작 | B의 렌더링 시작 (백그라운드) |
B → C | B의 렌더링 완료 전에 C 탭으로 전환 | B 렌더링 중단 → C 렌더링 시작 |
C → A | C의 렌더링 완료 전에 A 탭으로 전환 | C 렌더링 중단 → A 탭으로 즉시 복귀 |
- A 탭만 실제 DOM에 반영된다.
- B와 C 탭의 렌더링은 중간에 취소되며, 백그라운드에서 완료되지 않는다.
isPending
은 A 탭 복귀 후false
가 된다.