렌더 흐름에서 규칙이 걸리는 자리

렌더 흐름. 클라이언트: render → commit(DOM 반영·ref 콜백) → useLayoutEffect(페인트 전) → paint → useEffect(페인트 후) → … → 노드 제거(ref cleanup). 서버: render → HTML 문자열, useLayoutEffect 는 실행 안 됨·경고(React 18 이하), useEffect 는 실행 안 됨. ① render 중 useMemo: 캐시는 버려질 수 있다, 순수하면 재계산돼도 같은 값, 유지해야 하면 useState(init). ② useLayoutEffect: 페인트 전에 DOM 을 고쳐 flash 를 막는다, 서버엔 이 단계가 없어 React 18 이하는 경고가 난다, shim 으로 서버에선 useEffect. ③ useEffect: ref.current 가 채워진 뒤라 ownerDocument.defaultView = 노드가 실제로 사는 창. ④ commit 의 ref 콜백이 cleanup 을 반환하고 노드 제거 때 실행된다 → 리스너 수명 = 노드 수명.

① render — 유지돼야 하는 값은 useMemo 가 아니라 state

useMemo(getRandomColors, []) 로 만든 값이 HMR 뒤에 바뀐다.1

useMemo(calc, []): 마운트 → 캐시 없음. 캐시 없음 → 렌더·계산 → 캐시 있음(값 v). 캐시 있음 → deps 같음 → 재사용. 캐시 있음 → 캐시 폐기(HMR·첫 마운트 중 suspend, 앞으로는 화면 밖도) → 캐시 없음. 다시 계산하면 순수는 같은 v 라 드러나지 않고, 랜덤은 다른 v′ 라 값이 바뀐다. useState(init): 마운트·init 1회 → state(값 v). state → 리렌더·HMR → 유지. state → 언마운트 → 끝. 값이 사라지는 길은 언마운트뿐.

const resolved = resolveTheme(mode, systemDark) // 순수 파생은 그냥 계산
const [colors] = useState(getRandomColors) // 유지가 곧 correctness 면 state

② useLayoutEffect — 서버 경고는 shim 으로 (React 18 이하)2

페인트 전에 DOM 을 고쳐야 하나? 아니오 → useEffect. 예 → SSR 도 하나? 아니오 → useLayoutEffect. 예 → useIsoLayoutEffect. useIsoLayoutEffect 는 서버에선 useEffect 가 되어 실행 안 되고 경고도 없으며, 브라우저에선 useLayoutEffect 가 되어 페인트 전에 실행된다.

const useIsoLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect

③ useEffect — 이벤트는 노드가 사는 window 에

window.open·포털로 다른 창에 렌더해도 window.addEventListener 는 원래 창을 듣는다.3

컴포넌트 코드는 원래 창에서 돌고, window.open·포털로 렌더한 DOM 노드(ref.current)는 새 창에 산다. window.addEventListener → 원래 창의 window 에 붙는다, 노드가 없는 창이다. ref.current.ownerDocument.defaultView → 새 창의 window 에 붙는다, 노드가 사는 창이다.

const win = ref.current?.ownerDocument.defaultView ?? window

멀티윈도우로 쓰일 수 있는 라이브러리 컴포넌트에만 필요하다.

④ ref 콜백 — cleanup 을 반환한다 (React 19)4

컴포넌트가 마운트된 동안 DOM 노드가 A 에서 B 로 교체되고, 마지막에 언마운트된다. useEffect + ref: effect 가 한 번 돌아 ref.current(=A) 에 리스너를 붙인다. A 가 떠난 뒤에도 리스너는 떠난 A 에 남아 언마운트까지 가고, B 에는 리스너가 없다. ref 콜백: ref(A) 가 A 에 붙이고 A 가 떠날 때 cleanup 이 abort() 로 떼며, ref(B) 가 B 에 붙이고 B 가 떠날 때 cleanup 이 뗀다. effect 는 컴포넌트 수명, ref 콜백은 노드 수명을 따른다.

<div
  ref={(node) => {
    const ctrl = new AbortController()
    node.addEventListener('scroll', onScroll, { signal: ctrl.signal })
    return () => ctrl.abort()
  }}
/>

Footnotes

  1. “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 useMemo solely 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) ↩

  2. React 19.0.0 에서 경고가 빠졌다 — “Remove layout effect warning on server” (CHANGELOG — React 19.0.0) ↩

  3. ownerDocument 는 “returns the top-level document object of the node”, defaultView 는 “returns the window object associated with a document, or null if none is available.” (Node.ownerDocument — MDN · Document.defaultView — MDN) ↩

  4. “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) ↩

`#594`