프로그래머스, 타입스크립트로 함께하는 웹 풀 사이클 개발(React, Node.js) 5기 강의에서 번외로 진행되는 활동에 대한 내용을 정리하는 포스팅.
React.js (19버전)을 주제로 삼아 진행되는 스터디.
useInsertionEffect는 CSS-in-JS 라이브러리 작성자를 위한 Hook.
layout Effects가 실행되기 전에 스타일을 DOM에 주입할 수 있게 한다.


useEffect(() => {
console.log("렌더링 이후 실행");
}, []);
useLayoutEffect(() => {
console.log("렌더링 이후, 브라우저가 그리기 전에 실행");
}, []);
useInsertionEffect(() => {
console.log("렌더링 이전에 실행 (주로 스타일 삽입)");
}, []);
effect: 실행할 작업을 정의하는 함수.
dependencies: 의존성 배열. 배열에 포함된 값이 변경될 때 effect가 재실행됨.
Style 속성을 CSS 파일이 아닌, JavaScript로 작성된 컴포넌트에 바로 삽입하는 기법.
자세한 설명은 이전 포스팅 참조.
useEffect는 화면이 렌더링 된 이후에, 실행될 작업들을 위해 사용하는 Hook.
useInsertionEffect는 화면이 렌더링 되기 전에, 스타일 적용을 위해 사용하는 Hook.
useEffect에서도 스타일 로직을 사용할 수는 있는데.. useEffect의 동작 원리상, 렌더링이 끝난 후에 스타일을 DOM에 추가되기 때문에 화면이 한 번 깜빡이는(FOUC, Flash of Unstyled Content) 현상이 발생할 수 있다. 그래서 useInsertionEffect를 사용하는 것.

맞다. 애초에 Styled-Components는 내부적으로 useInsertionEffect을 사용하고 있다.
(emotion 라이브러리도 마찬가지.)
달리 말하자면.. useInsertionEffect을 더 편하고 직관적으로 사용하기 위해서 쓰는 것이 Styled-Components.
CSS-in-JS를 직접 하겠다고 useInsertionEffect을 사용해보면.. 라이브러리를 왜 사용하는지 잘 알게 된다.
아래 예제 참조..
// 라이브러리를 사용하지 않았을 때..
import { useInsertionEffect } from 'react';
function useCSS(rule) {
useInsertionEffect(() => {
const style = document.createElement('style');
style.textContent = rule;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, [rule]);
return rule;
}
function MyComponent() {
useCSS('.myClass { color: red; }');
return <div className="myClass">Hello, World!</div>;
}
// styled-components를 사용했을 때.
import styled from 'styled-components';
const StyledDiv = styled.div`
color: red;
`;
function MyComponent() {
return <StyledDiv>Hello, World!</StyledDiv>;
}
..그리고 styled-components도 동적 스타일링 구현이 가능하다. 더 편하게..
이전 포스팅 참조.
useLayoutEffect는 브라우저가 화면을 다시 그리기 전에 동기적으로 실행되는 Hook.
DOM을 읽거나 수정하는 작업을 수행할 수 있음.
과도하게 사용하면 성능에 영향을 줄 수 있으므로, 필요한 경우에만 사용을 권장.
effect: 렌더링 이후 DOM을 읽거나 수정할 작업을 정의하는 함수.
dependencies: 의존성 배열. 배열의 값이 변경될 때 effect가 재실행됨.

useLayoutEffect는 useEffect와 하는 역할은 동일하다.
그런데 DOM을 조작해야하는 경우.
useEffect는 렌더링 된 이후에, 실행될 작업들을 위해 사용하는 Hook 이기 때문에 여기서 DOM을 조작하게 되면 위에서 언급했던 화면이 한 번 깜빡이는(FOUC, Flash of Unstyled Content) 현상이 발생해버린다.
따라서, useEffect이지만 DOM 조작이 필요한 경우에는 useLayoutEffect이라는 별도의 Hook을 사용하라는 것이다.
화면 렌더링이 완료 -> 화면이 출력(layout과 paint) -> useEffect가 동작.
화면 렌더링이 완료 -> useLayoutEffect이 동작 -> 화면이 출력(layout과 paint).
import React, { useEffect, useState } from 'react';
function Example() {
const [data, setData] = useState(null);
useEffect(() => {
console.log('Fetching data...');
fetch('/api/data')
.then((response) => response.json())
.then((data) => setData(data));
}, []);
return <div>Data: {data || 'Loading...'}</div>;
}
import React, { useLayoutEffect, useRef, useState } from 'react';
function Example() {
const divRef = useRef(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
const { width, height } = divRef.current.getBoundingClientRect();
setDimensions({ width, height });
}, []);
return (
<div ref={divRef}>
Width: {dimensions.width}, Height: {dimensions.height}
</div>
);
}
useSyncExternalStore는 외부 스토어와 컴포넌트를 동기화하기 위한 Hook.
외부 스토어의 상태를 구독하고, 변경 사항이 발생하면 컴포넌트를 다시 렌더링한다.
subscribe: 외부 스토어의 상태 변경을 감지하는 함수.
- 매개변수 listener는 상태 변경 시 호출되는 콜백 함수.
- 반환값으로 구독을 해제하는 함수(unsubscribe)를 반환해야 함.
getSnapshot: 현재 외부 스토어의 상태를 반환하는 함수.
getServerSnapshot (선택적): 서버 렌더링 시 상태를 반환하는 함수. (SSR 환경에서 사용)
전역 상태 관리에서 사용되는, 모든 상태State를 관리하는 객체.
전역 상태 관리에 대해서는 이전 포스팅 참조.

(참고), Recoil은 더 이상 사용하지 않는게 좋다. 해당 포스팅 참조.
- 한 때는 각광받았는데.. 지금은 메인 업데이트는 2년 넘게 끊어졌고, 성능 및 최적화 문제가 제기되고, SSR 환경에 적합하지 않고, 메인 개발자가 해고당하는 악재까지..
말이 좀 샜는데.. 아무튼 useSyncExternalStore Hook은 별도의 외부 상태 관리 라이브러리 등을 사용할 때 혹은 App 바깥에서 값을 가져올 때 등의 상황이 있다고 가정해보자.
가져온 값이 변경될 때, React 컴포넌트에서 리렌더링이 발생하는 것은 지극히 당연한 일이다.
그런데.. React 18에서 UI의 동시적 업데이트를 지원하는 여러 기능들이 추가되면서, React에 종속되어있지 않은 외부 상태 관리 라이브러리 등과 React App 사이에 Tearing 문제가 발생하는 일이 생겼다.
동일한 데이터 소스에 따라 렌더링이 이뤄지는 두 가지 UI가 있다고 할 때..
동시적 업데이트 이전에는 순서대로 작업이 잘 이루어졌는데..
이제는 어느 한 쪽의 작업이 실패하거나, 지연되거나 하는 등의 이유로 '동일하지 못한 데이터 소스'에 의해 여러 UI가 렌더링 되어버릴 수도 있게 되었다.
값이 잘못되면, UI도 이상하게 출력된다는 것.
useSyncExternalStore는 외부 전역 상태 관리 라이브러리를 사용하는 경우에 사용할 수 있는 녀석이다.
Recoil은.. 이 녀석은 React 개발진에서 만든 녀석이라 따로 뭘 해줄 필요는 없다고 한다.
- 근데 라이브러리가 망했네?
Redux는.. 내부적으로 useSyncExternalStore를 쓰도록 변경되었다고 한다. 따로 조치를 취할 필요는 없는 것 같고..
Zustand는.. use-sync-external-store라고 useSyncExternalStore을 사용하는 패키지가 따로 있는데, 요 녀석을 설치해서 사용하면 useSyncExternalStore의 기능을 사용할 수 있다고 한다.
- 필수는 아니다. useSyncExternalStore 기능을 원하는 경우에만 패키지를 설치해서 사용하라는 뜻.
Jotai는.. 개발자 피셜로 useSyncExternalStore를 안쓴다는 것 같다? 자체적인 방식으로 문제를 해결한다고..
Jotai 개발자가 말하는 내용을 번역하고, 이를 이해하기 위해 최대한 노력해보았다..
useSyncExternalStore는 만능이 아니고 오히려 최적화에 방해가 될 수 있다.
Time Slicing과 useTransition을 잘 지원하기 위해서 useSyncExternalStore 대신 useState와 useReducer를 사용한다.
useSyncExternalStore는 동기적으로 상태를 가져오기 때문에, 상태를 구독하는 모든 컴포넌트를 즉시 업데이트해야한다.
그래서 React의 Time Slicing을 무효화(de-optimize)시키며, 작업을 분할하거나 우선순위를 조정할 수 없다.
useSyncExternalStore는 업데이트를 강제로 즉시 반영하기 때문에, useTransition으로 비긴급 업데이트를 처리할 때 적절히 대기 상태를 표시하지 못할 수 있다.
예를 들어, 데이터가 변경될 때 "Pending…" 상태를 보여주고 싶어도 useSyncExternalStore는 즉시 렌더링을 강제하여 이러한 UX를 방해할 수 있는 것.
그럼 어떤 방법을 사용하지? -> useState와 useReducer만을 사용하여 Time Slicing을 방해하지 않고 작업을 스케줄링하고 / useTransition과 더 자연스럽게 연동되어 대기 상태를 효과적으로 처리하고 / 그러면서도 기본 상태 관리 메커니즘과 밀접하게 통합되어 React 18의 동시성 기능을 최대한 활용한다고..
React 18의 동시성 기능 중 하나, 작업을 분할(Slicing)하여 브라우저의 메인 스레드가 과부하되지 않도록 한다.
높은 우선순위 작업(예: 사용자 입력, 애니메이션)과 낮은 우선순위 작업(예: 렌더링)을 구분하여 처리.
getSnapshot을 동기적으로 호출하여, 외부 상태 저장소의 최신 상태를 항상 정확히 가져온다.
상태 변경이 발생하면 저장소에서 구독 중인 모든 컴포넌트가 동일한 업데이트 주기 안에서 다시 렌더링시킨다.
- 여러 컴포넌트들이 공유하는 상태가 일치하지 않는Tearing 현상을 방지한다는 것.
다만 이렇게되면 불필요한 리렌더링이 발생할 수 있다. 따라서 getSnapshot의 반환값을 메모이제이션하여, 동일한 상태 값이 반복적으로 렌더링되는 것을 방지한다.
(SSR에서는) getServerSnapshot을 활용해 클라이언트 초기 상태와 서버 상태 간의 불일치를 방지한다.
- 클라이언트가 초기 상태를 렌더링하기 전에 서버에서 가져온 상태와 동기화하므로 UI가 일관성을 유지시킬 수 있다.
import { useSyncExternalStore } from 'react';
const store = {
state: 0,
listeners: new Set(),
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
},
increment() {
this.state += 1;
this.listeners.forEach((listener) => listener());
},
getSnapshot() {
return this.state;
},
};
function Counter() {
const count = useSyncExternalStore(
(listener) => store.subscribe(listener),
() => store.getSnapshot()
);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => store.increment()}>Increment</button>
</div>
);
}
useTransition은 상태 업데이트를 전환 상태로 표시하여 비동기 작업의 진행 상태를 관리할 수 있게 해주는 Hook
사용자 인터페이스의 응답성을 향상시킬 수 있음.
React에서는 여러 상태가 '동시에 업데이트'될 때, UI가 잠시 멈추거나 과부하가 발생할 수 있다.
A, B, C, ... 여러 상태들이 동시에 업데이트되면, 컴포넌트가 한꺼번에 리렌더링되고..
많은 작업이 몰리면서 응답이 지연되고, 자칫 잘못하면 UI가 멈춰버리는 일까지 발생할 수 있다.
작업이 완료되기 전까지, UI 변화나 동작을 잠시 막을 수도block 있겠지만.. 사용자 경험 측면에서 좋은 방법은 아니다.
그럴 때 사용하는 것이 useTransition.
useTransition을 사용하면 작업을 우선순위로 분류하여 처리한다.
- 높은 우선순위: 즉각적인 상태 업데이트
- 낮은 우선순위: 느린 작업 처리

A 상태가 업데이트되고, UI가 리렌더링 된다. - ?순위
B 상태가 업데이트되고, UI가 리렌더링 된다. - ?순위
C 상태가 업데이트되고, UI가 리렌더링 된다. - ?순위
D 상태가 업데이트되고, UI가 리렌더링 된다. - ?순위
E 상태가 업데이트되고, UI가 리렌더링 된다. - ?순위
F 상태가 업데이트되고, UI가 리렌더링 된다. - ?순위
=> CBEDAF..?? 모든 작업들이 자기가 먼저 실행되겠다고 나서다가 뻗어버렸다.

A 상태가 업데이트되고, UI가 리렌더링 된다. - 1순위
B 상태가 업데이트되고, UI가 리렌더링 된다. - 2순위
C 상태가 업데이트되고, UI가 리렌더링 된다. - 3순위
D 상태가 업데이트되고, UI가 리렌더링 된다. - 4순위
E 상태가 업데이트되고, UI가 리렌더링 된다. - 5순위
F 상태가 업데이트되고, UI가 리렌더링 된다. - 6순위
=> A, B, C, D, E, F. 작업들이 순차적으로 하나씩 실행되면서 아무 문제도 발생하지 않는다.
import React, { useState, useTransition } from "react";
function SearchComponent({ items }) {
const [query, setQuery] = useState(""); // 검색어
const [filteredItems, setFilteredItems] = useState(items); // 필터링된 결과
const [isPending, startTransition] = useTransition(); // useTransition 사용
const handleChange = (e) => {
const value = e.target.value;
setQuery(value); // 높은 우선순위 업데이트
startTransition(() => {
// 낮은 우선순위 업데이트
const filtered = items.filter((item) =>
item.toLowerCase().includes(value.toLowerCase())
);
setFilteredItems(filtered);
});
};
return (
<div>
<input type="text" value={query} onChange={handleChange} />
{isPending && <p>Loading...</p>}
<ul>
{filteredItems.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
}
setQuery가 높은 우선순위, 아무런 지연없이 바로 상태 업데이트가 이루어지도록 한다.
startTransition 함수에서 콜백 함수 형태로 넘겨진 코드가 낮은 우선순위.
낮은 우선순위의 작업은, 높은 우선순위 작업이 다 이루어진 뒤에 실행되도록 한다.
이렇게 순차적으로 작업이 진행되도록 해서 순간적인 작업량 증가로 인해 UI에 문제가 발생하기 않도록 하는 것이다.
조금 더 자세한 내용은 외부 포스팅 참고.
useOptimistic은 비동기 작업이 진행 중일 때 낙관적인 UI 업데이트를 가능하게 하는 Hook
네트워크 요청 등의 비동기 작업이 완료되기 전에 사용자에게 예상 결과를 미리 보여줄 수 있음.
initialState: 낙관적 상태 관리의 초기 상태.
reducer: 상태 업데이트 로직을 정의하는 함수.
- state: 이전 상태.
- action: 상태를 업데이트하는 데 필요한 값.
비동기 작업이 모두 완료된 뒤, 데이터를 상태에 업데이트하고, UI를 리렌더링 한다.
그런데 비동기 작업 특성상 결과가 언제 반환될지는 미지수. 결과를 기다릴 때까지 UI 렌더링을 계속 기다릴 수도 없다..
따라서, 비동기 작업 결과를 '예상'하여 이를 기반으로 UI를 먼저 업데이트하는 것.
작업이 성공했을 때를 '예상'하기 때문에, '낙관적인' UI 업데이트라고 지칭한다.
물론 작업이 실패하면 롤백.
import React, { useState, useOptimistic } from "react";
function Comments() {
const [comments, setComments] = useState(["First comment", "Second comment"]);
const [optimisticComments, setOptimisticComments] = useOptimistic(comments, (prev, newComment) => {
return [...prev, newComment]; // 새 댓글 추가
});
const handleAddComment = async (newComment) => {
// 낙관적 상태 업데이트
setOptimisticComments(newComment);
try {
// 서버에 새 댓글 저장 요청
await fetch("/api/addComment", {
method: "POST",
body: JSON.stringify({ comment: newComment }),
});
} catch (error) {
// 서버 요청 실패 시 롤백
alert("Failed to add comment");
setOptimisticComments((prev) => prev.slice(0, -1)); // 마지막 댓글 삭제
}
};
return (
<div>
<ul>
{optimisticComments.map((comment, index) => (
<li key={index}>{comment}</li>
))}
</ul>
<button onClick={() => handleAddComment("New comment")}>Add Comment</button>
</div>
);
}
댓글을 작성한다 -> 서버로 전송한다 -> 작업이 정상적으로 완료된다 -> 댓글 데이터를 새로 받아 화면을 갱신한다.
원래대로면 위 순서대로 작업을 진행해야겠지만..
사용자 입장에서는 내가 댓글을 새로 입력했는데, 화면이 업데이트 되는 것이 '너무 느리다'고 생각할 수 있다.
- 길어봐야 몇 초 차이인데 이게 그렇게 문제가 되냐 싶지만..
- 모 설문 조사에서는 사용자들은 평균적으로 3초에서 5초 내로 화면이 렌더링 되지 않으면 그 페이지를 그냥 나가버렸다는 결과도 있다.
- 불과 몇 초 때문에 사용자 평가가 나빠질 수 있는 것..
따라서 setOptimisticComments를 통해 '작업이 성공했다 치고', 새 댓글이 작성된 것 처럼 화면을 업데이트하는 것이다.
- 입력한 댓글이 서버에 업로드 되지도 않았는데, 어떻게 화면을 업데이트하지?
- 위 예제의 경우.. 사용자가 댓글 데이터를 입력하면, 이 데이터를 그대로 서버에 전송하면서, 클라이언트에서는 낙관적인 UI 업데이트에 사용하게 된다.
서버에 데이터가 성공적으로 업로드 되었다? 잘 된 일이다.
실패했다? 작업 내용을 롤백하면 된다.