<div
dangerouslySetInnerHTML={{
__html: `
<img src="http://unsplash.it/100/100?random" onload="console.log('you got hacked');" />
`,
}}
/>
가끔 아주 가끔 이상한 일을 해야할때가 있는데 그럴때는 이렇게 하면 된다.
dangerouslySetInnerHTML 은 innerHTML 을 덮어쓰는 것이라1 <script> 는 실행되지 않지만 이벤트 핸들러 속성(onload·onerror)은 실행된다.2
Footnotes
-
Common components – React: “Overrides the innerHTML property of the DOM node and displays the passed HTML inside.” ↩
-
Element: innerHTML property - MDN: “While the property does prevent
곁가지 —
@starting-style은 같은 뿌리의 반대편이다. transition은 두 상태 사이를 보간하는데, 진입에는 옮겨갈 이전 상태가 없고2 퇴장에는 요소 자체가 이미 사라진다.@starting-style은 앞쪽의 출발값을 대신 정해주고(진입 fade-in),useMinimumLoading은 뒤쪽의 unmount를 미룬다. 뿌리는 같아도 손대는 지점이 달라 별개 결정이다.Footnotes
-
Chrome 154 headless 에서 raw CDP 로 실측 (2026-09-30).
/api요청을Fetch.requestPaused로 100·350ms 붙잡고, 스피너(animation: fade 100ms <지연> both)의opacity를 매requestAnimationFrame마다 읽었다. 프로브를 먼저 걸고 버튼 클릭으로 스피너를 띄운 실행에서도 hold 100 → 0프레임, hold 350 → 2프레임(34ms)으로 같았다. 프로브 대 trace 비교는 스피너를 400px 빨간 상자로 두고Tracing의Screenshot이벤트를 받아, JPEG 를sips로 BMP 로 바꿔 상자 안 한 점의 픽셀을 읽었다 — hold 350 에서(255,199,200)→(255,109,110)→(255,51,52)→ 흰색. ↩ -
@starting-style — MDN: “CSS transitions are by default not triggered on an element’s initial style update, or when its display type changes from none to another value. To enable first-style transitions, @starting-style rules are needed.” ↩
-
Suspense는 비동기를 동기처럼 보이게 만든다. 데이터가 아직 없으면 promise를 던지고, React가 그걸 받아 기다렸다가 다시 그린다.1 그래서 상태를 조율하는 대신 입력을 미루고 → 질의하고 → 그리는 순서로 끝난다.
언제: 비동기 데이터를 그리는데 로딩·에러가 늘면서 화면이 튈 때. 또는 새로 만들면서 어느 쪽으로 갈지 고를 때.
절차
- 검색어·결과·로딩 중인지·에러인지를 각각 들고 있는지 본다.
- 분기를
if/else에서 트리로 옮긴다 — 로딩은<Suspense>, 에러는<ErrorBoundary>, 성공은 본문. - 본문에 loading·error를 인자로 넘기지 않는다. 안 넘겨도 가장 가까운 경계가 대신 잡기 때문이다2 —
render는 결과 하나만 받는다.
확인: 있을 수 없는 조합을 적을 수 있는가. 적을 수 없으면 된 것이다.
함정: 스피너가 너무 빨리 뜨고 사라지는 건 이 축으로 안 고쳐진다 — 다른 축이다 → 573
곁가지 — 인자를 위로 들어올려 바깥이 처리하게 하는 이 모양은 대수적 효과(algebraic effects)의 handler에서 따왔다.3
stale-while-revalidate(두 시계·isStale) 부분은 570으로.
Footnotes
-
“Technically, in that case the read() call throws a Promise (yes, throws a Promise — let that sink in). This “suspends” the execution. React catches that Promise, and remembers to retry rendering the component tree after the thrown Promise resolves.” (Algebraic Effects for the Rest of Us) ↩
-
“When a component suspends, the closest parent Suspense component shows the fallback.” (Suspense — React) ↩
-
try/catch와 비슷하다 — 중간 함수들은 몰라도 되고 가장 가까운 handler가 받는다. 다른 점은 handler가 값을 돌려주면 효과를 일으킨 자리로 되돌아가 실행이 이어진다는 것이다 — “It lets us jump back to where we performed the effect, and pass something back to it from the handler.” Suspense는 되돌아가지 않고 다시 그린다: “This isn’t an algebraic effect per se, even though this trick was inspired by them.” … “Of course, we can’t really resume execution in JavaScript later, but from React’s point of view, re-rendering a component tree when the Promise resolves is pretty much the same thing.” (Algebraic Effects for the Rest of Us) ↩
useMemo 캐시 적중 진단 — 매 렌더 새로 만들어지는 deps
무거운 계산을 useMemo로 감쌌는데 렌더 시간이 줄지 않는다. 각 단계는 질문 하나에 답하고, 그 답이 다음 질문을 만든다. 단계마다 추론이 아니라 관찰로 넘어간다.
1. 정말 이 계산이 느린가
React DevTools Profiler로 조작 하나를 녹화하고, flamegraph에서 그 컴포넌트의 렌더 시간(ms)을 본다. 여기서 이 컴포넌트가 병목이 아니면 useMemo를 볼 이유가 없다. 이 숫자는 5에서 수정 전후를 비교할 기준선이 된다.
2. 정말 재사용되고 있나
계산 함수 안에 카운터를 넣고 리렌더를 몇 번 일으킨다.
const rows = useMemo(() => {
console.count('calc')
return items.filter(fn)
}, [items])
calc가 렌더와 같이 늘어나면 한 번도 재사용되지 않고 있다. 개발 모드에서는 React가 계산 함수를 일부러 두 번 부르므로1 렌더당 2씩 뛰는 게 정상이다 — 보는 건 절대값이 아니라 렌더를 따라 늘어나는지다.
CDP로 읽을 때. Runtime.enable 뒤 Runtime.consoleAPICalled가 type: "count"로 온다. 숫자는 따로 오지 않고 args[0].value에 "calc: 3" 같은 문자열 하나로 오므로 /: (\d+)$/로 꺼낸다. console.countReset은 이벤트를 내지 않는다. Runtime.enable 전에 찍힌 메시지도 enable 시점에 다시 보내준다.2
코드를 고치지 않고 셀 때. 앱에 로그를 심을 수 없으면 V8 커버리지의 호출 횟수를 쓴다.
await send('Profiler.enable')
await send('Profiler.startPreciseCoverage', { callCount: true, detailed: true })
// 리렌더 한 번 일으키고
const { result } = await send('Profiler.takePreciseCoverage')
// result[].functions[].ranges[0].count = 그 사이 호출 횟수
takePreciseCoverage는 부를 때마다 카운터를 0으로 되돌린다.2 리렌더마다 한 번씩 부르면 그 렌더 동안의 호출 횟수가 바로 나온다. 다만 useMemo(() => …)의 화살표 함수는 functionName이 비어 있어 스크립트 url과 ranges 오프셋으로 찾아야 하고(번들이면 소스맵으로 되짚는다), 개발 모드의 두 번 호출도 그대로 세어진다.
3. 왜 매번 다시 계산하나
계산 쪽이 아니라 deps를 의심한다. React는 재료 안을 들여다보지 않고 Object.is로 같은 물건인지만 본다.1 매 렌더 새로 만들어지는 값이 deps에 있으면 내용이 같아도 매번 “다른 재료”다.
의심만 하지 말고 참조가 실제로 바뀌는지 찍는다.
const prev = useRef(items)
useEffect(() => {
console.log('items same?', Object.is(prev.current, items))
prev.current = items
})
매 렌더 false면 확정이다. Profiler 설정의 “Record why each component rendered while profiling”을 켜면 코드 없이 Props changed: items로도 보인다.
4. 누가 새 참조를 만드나
같은 비교를 부모 쪽에 두고 거슬러 올라간다. 렌더 중에 새 배열을 만들어 넘기고 있었다.
<List items={data.map(transform)} />
넘기는 쪽에서 참조를 붙잡아 둔다.
const items = useMemo(() => data.map(transform), [data])
Footnotes
-
“React will compare each dependency with its previous value using the
Object.iscomparison.” / “React will not throw away the cached value unless there is a specific reason to do that … This should be fine if you rely onuseMemosolely as a performance optimization.” / “In Strict Mode, React will call your calculation function twice in order to help you find accidental impurities.” (useMemo — React) ↩ ↩2 -
Chrome 154 headless 에서 원시 CDP 로 실측 (2026-09-30).
console.count('calc')세 번 →"calc: 1"·"calc: 2"·"calc: 3",countReset뒤 다시"calc: 1". 커버리지는 3회 호출 뒤take→ 3, 2회 더 호출 뒤take→ 2. ↩ ↩2
렌더 흐름에서 규칙이 걸리는 자리
① render — 유지돼야 하는 값은 useMemo 가 아니라 state
useMemo(getRandomColors, []) 로 만든 값이 HMR 뒤에 바뀐다.1
const resolved = resolveTheme(mode, systemDark) // 순수 파생은 그냥 계산
const [colors] = useState(getRandomColors) // 유지가 곧 correctness 면 state
② useLayoutEffect — 서버 경고는 shim 으로 (React 18 이하)2
const useIsoLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect
③ useEffect — 이벤트는 노드가 사는 window 에
window.open·포털로 다른 창에 렌더해도 window.addEventListener 는 원래 창을 듣는다.3
const win = ref.current?.ownerDocument.defaultView ?? window
멀티윈도우로 쓰일 수 있는 라이브러리 컴포넌트에만 필요하다.
④ ref 콜백 — cleanup 을 반환한다 (React 19)4
<div
ref={(node) => {
const ctrl = new AbortController()
node.addEventListener('scroll', onScroll, { signal: ctrl.signal })
return () => ctrl.abort()
}}
/>
Footnotes
-
“For example, in development, React throws away the cache when you edit the file of your component. Both in development and in production, React will throw away the cache if your component suspends during the initial mount.” / “This should be fine if you rely on
useMemosolely as a performance optimization. Otherwise, a state variable or a ref may be more appropriate.” / “In the future, React may add more features that take advantage of throwing away the cache—for example, if React adds built-in support for virtualized lists in the future, it would make sense to throw away the cache for items that scroll out of the virtualized table viewport.” (useMemo — React) ↩ -
React 19.0.0 에서 경고가 빠졌다 — “Remove layout effect warning on server” (CHANGELOG — React 19.0.0) ↩
-
ownerDocument는 “returns the top-level document object of the node”,defaultView는 “returns the window object associated with a document, ornullif none is available.” (Node.ownerDocument — MDN · Document.defaultView — MDN) ↩ -
“When that
<div>DOM node is removed, React will call your the cleanup function returned from the callback.” / “React 19 added cleanup functions for ref callbacks.” (Common components — React) ↩