<div
  dangerouslySetInnerHTML={{
    __html: `
      <img src="http://unsplash.it/100/100?random" onload="console.log('you got hacked');" />
    `,
  }}
/>

가끔 아주 가끔 이상한 일을 해야할때가 있는데 그럴때는 이렇게 하면 된다.


#193
#200

lazy로 import했을 경우 ChunkLoadError가 발생하는데 이럴 경우 어떻게 대응할 수 있는지 정리해둔 글들.

#203

모바일 웹뷰에서 특정 영역을 zoom할수 있게 해달라는 요청이 있어서 적용한 내역. 정확히 기억은 안나는데 react-prismazoom를 선택했다.


그 외 비슷한

#269
const root = createRoot(document.querySelector('#confirm-root'))

root.render(<Confirm />)

confirm ui를 만들다보면 window.confirm을 호출하는 방식으로 사용하는게 가장 좋은 방법인데 (안그러면 state로 관리해야하고 결국 이건 무의미한 코드의 반복이다.) 이걸 react로 구현하려면 결국 render를 사용해야함. react-confirm, react-confirm-alert 둘다 소스를 보면 비슷한 방식으로 접근한다.

#270

Avoiding premature abstraction with Unstyled React Components (buildui.com)

React 컴포넌트를 작성할 때, 불필요한 추상화를 피하고 컴포넌트의 유연성을 유지하는 방법. 특히 스타일이 없는 컴포넌트를 통해 어떻게 컴포넌트의 기능에 집중할 수 있는지를 설명.

import React, { ComponentProps, FC, ReactNode } from 'react'

function Spinner() {
  return (
    <span className="absolute inset-0 flex items-center justify-center">
      `<Spinner />`
    </span>
  )
}

type LabelProps = ComponentProps<'span'>

function Label({ children, ...rest }: LabelProps) {
  return <span {...rest}>{children}</span>
}

type LoadingButtonProps = ComponentProps<'button'> & {
  children: FC | ReactNode
}

/**
 * @description 버튼이 비활성화되었을 때 로딩 스피너를 표시할 수 있는 버튼 컴포넌트입니다. 이 컴포넌트는 children 속성으로 함수나 React 노드를 받을 수 있으며, 버튼이 비활성화되면 스피너가 표시되고, 텍스트는 보이지 않게 됩니다.
 */
function LoadingButton({
  disabled,
  className,
  children,
  ...rest
}: LoadingButtonProps) {
  return (
    <button {...rest} className={`${className} relative`} disabled={disabled}>
      {typeof children === 'function' ? (
        children({})
      ) : (
        <>
          {disabled && <Spinner />}
          <Label className={disabled ? 'invisible' : ''}>{children}</Label>
        </>
      )}
    </button>
  )
}

LoadingButton.Spinner = Spinner
LoadingButton.Label = Label

이 패턴은 컴포넌트를 작성할 때 불필요한 스타일링이나 구조를 미리 정의하지 않고, 각 컴포넌트가 자신의 역할에 충실할 수 있도록 도와줍니다. 이를 통해 코드의 유연성을 유지하고, 필요에 따라 컴포넌트를 확장하거나 수정할 수 있는 여지를 남겨두게 됩니다.

#311
💡

이 패턴이 “상태 머신”처럼 들린다면, 그리 놀랄 일도 아닙니다. 결국, 선택의 문제는 상태 머신을 구축할지 말지가 아니라, 그것을 암시적으로 구축할지 명시적으로 구축할지에 달려 있습니다.

stateDiagram-v2
  [*] --> Mounting

  Mounting --> AwaitingEmailInput
  Mounting --> AwaitingCodeInput

  AwaitingEmailInput --> SubmittingEmail

  SubmittingEmail --> AwaitingCodeInput
  SubmittingEmail --> AwaitingEmailInput

  AwaitingCodeInput --> SubmittingCode

  SubmittingCode --> Success
  SubmittingCode --> AwaitingCodeInput

  Success --> [*]

#319

폼 제출 시 FormData 테스트

test('폼 제출 시 formData 확인', () => {
  const handleSubmit = jest.fn((e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.target as HTMLFormElement)
    const data = Object.fromEntries(formData.entries())
    expect(data).toEqual({ username: 'testuser', password: 'password' })
  })

  render(<LoginForm onSubmit={handleSubmit} />)

  fireEvent.change(screen.getByPlaceholderText('아이디'), {
    target: { value: 'testuser' },
  })
  fireEvent.change(screen.getByPlaceholderText('비밀번호'), {
    target: { value: 'password' },
  })
  fireEvent.click(screen.getByRole('button', { name: '로그인' }))

  expect(handleSubmit).toHaveBeenCalled()
})
#326

AuthContext 테스트 패턴

// localStorage 함수 테스트
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => {
  if (key === 'auth_token') return 'test_token'
  return null
})

// AuthProvider 상태 테스트
const TestComponent = () => {
  const { token, login, logout } = useAuth()

  return (
    <div>
      <p data-testid="token">{token || 'null'}</p>
      <button onClick={() => login('test_token')}>Login</button>
      <button onClick={logout}>Logout</button>
    </div>
  )
}

// useAuth 훅 테스트
it('AuthProvider 외부에서 호출하면 에러', () => {
  expect(() => renderHook(() => useAuth())).toThrowError()
})

it('AuthProvider 내부에서 정상 동작', () => {
  const wrapper = ({ children }) => <AuthProvider>{children}</AuthProvider>
  const { result } = renderHook(() => useAuth(), { wrapper })
  expect(result.current).toHaveProperty('token', null)
})
#328

조건부 렌더링을 한다고 했을때 예전에는 주로 B로 처리했던 것 같은데 디버깅 때문에 고생해서 그런지 생각이 바뀌었다.

function renderA() {
  return <Item isPacked={true} name="Space suit" />
}

function renderB() {
  return <>{isPacked ? <Item name="Space suit" /> : null}</>
}

조건부 렌더링을 보다 간결하게 표현하기 위해 If, Then, 그리고 Else 컴포넌트 개념을 차용하는 방법. 이 컴포넌트는 If에서 condition prop을 통해 조건을 받아들이고, 자식으로 Then과 Else를 받아 각각의 내용을 렌더링하도록 한다.

type Props = {
  /** 렌더링할 조건 */
  condition: boolean
  /** 자식 컴포넌트 (Then, Else 포함) */
  children: React.ReactNode
}

/**
 * If 컴포넌트는 조건부 렌더링을 위한 컴포넌트
 */
function If({ condition, children }: Props) {
  let thenChild = null
  let elseChild = null

  React.Children.forEach(children, (child) => {
    if (!React.isValidElement(child)) return
    if (child.type === Then) thenChild = child
    if (child.type === Else) elseChild = child
  })

  return condition ? thenChild : elseChild
}

/**
 * Then, Else 컴포넌트는 조건이 참 또는 거짓일 때 렌더링되는 콘텐츠를 포함
 */
const Then = ({ children }: React.PropsWithChildren) => <>{children}</>
const Else = ({ children }: React.PropsWithChildren) => <>{children}</>
#335
💡

useStateObject는 React의 useState를 확장한 가벼운 래퍼로, 객체 상태 관리를 간편하게 할 수 있도록 설계되었습니다.


export type StateObject<T extends object> = T & {
  set: React.Dispatch<React.SetStateAction<T>>
  setItem: <K extends keyof T>(key: K, value: T[K]) => void
  merge: (newState: Partial<T>) => void
  reset: () => void
}

그렇다면 Map과 Set도 시도해보기

function useStateMap<K, V>(init: Iterable<[K, V]> = []) {
  const [map, setMap] = useState(new Map<K, V>(init))

  const update = useCallback(
    (updater: (currentMap: Map<K, V>) => void) => {
      setMap((prev) => {
        const newMap = new Map(prev)
        updater(newMap)
        return newMap
      })
    },
    [setMap]
  )

  return {
    map,
    set: (key: K, value: V) => update((m) => m.set(key, value)),
    delete: (key: K) => update((m) => m.delete(key)),
    clear: () => setMap(new Map()),
    has: (key: K) => map.has(key),
    get: (key: K) => map.get(key),
    entries: () => Array.from(map.entries()),
    size: map.size,
  }
}
function useStateSet<T>(init: Iterable<T> = []) {
  const [set, setSet] = useState(new Set<T>(init))

  const update = useCallback(
    (updater: (currentSet: Set<T>) => void) => {
      setSet((prev) => {
        const newSet = new Set(prev)
        updater(newSet)
        return newSet
      })
    },
    [setSet]
  )

  return {
    set,
    add: (value: T) => update((s) => s.add(value)),
    delete: (value: T) => update((s) => s.delete(value)),
    has: (value: T) => set.has(value),
    clear: () => setSet(new Set()),
    entries: () => Array.from(set),
    size: set.size,
  }
}

#354
function toggleReducer(state, action) {
  switch (action.type) {
    default:
      return state
  }
}

function useToggle({ reducer = toggleReducer } = {}) {
  const [state, dispatch] = useReducer(reducer, {})
  return { state, dispatch }
}

export function Component() {
  useToggle({
    reducer(currentState, action) {
      console.log(currentState, action)
    },
  })
}

useReducer를 이용한 커스텀훅 사용에 대한 간단한 예시. 생각해보니 reducer를 전달해서 재사용하는 방법은 잘 생각못했는데 응용할 수 있을 것 같다.



Footnotes

  1. 리듀서를 사용하여 예측 가능하고 테스트 가능한 방식으로 상태 업데이트 및 작업을 캡슐화하는 방법과 State Reducer 패턴을 사용하여 이를 사용하는 구성 요소에서 상태 업데이트를 추상화하여 해당 구성 요소가 특정 기능에 더 집중하도록 만드는 방법을 설명. ↩

#36
import * as React from 'react'

type ContainerProps = {
  children: typeof Body
}

type BodyProps = {
  id: string
}

function Container({ children }: ContainerProps) {
  const id = '1234'

  return <>{children({ id })}</>
}

export function Body({ id }: BodyProps) {
  return <>{id}</>
}

export function Page() {
  return <Container>{Body}</Container>
}

컨테이너/프레젠테이션 패턴을 활용한 React 컴포넌트 구조.

  • 컨테이너 컴포넌트(Container)는 상태를 관리하고 데이터를 하위 컴포넌트에 전달함.
  • 프레젠테이션 컴포넌트(Body)는 데이터를 받아서 UI를 렌더링함.
  • 이 구조는 테스트를 쉽게 하고, 컴포넌트의 역할을 명확하게 분리함.
#386

Footnotes

  1. React에서 컴포지션의 개념과 “Prop Drilling”을 피하기 위해 React에서 사용하는 방법을 설명. ↩

  2. Headless UI 컴포넌트의 개념을 소개. 헤드리스 UI 컴포넌트가 무엇인지, 기존 UI 컴포넌트와 어떻게 다른지 설명하고, 다양한 프로그래밍 언어와 프레임워크에서 헤드리스 UI 컴포넌트를 구현하는 방법에 대한 예제. ↩

#46

How To Maintain A Large Next.js Application — Smashing Magazine

  • TypeScript 사용
  • Lerna , Nx , Rush , Turborepo , yarn workspaces를 사용하여 Mono-Repo 구조 사용
  • Hygen과 같은 코드 생성기를 사용하여 상용구 코드 생성
  • Redux 툴킷을 통해 하위 상용구와 함께 Redux와 같이 잘 설정된 패턴 사용
  • 비동기 데이터를 가져오기 위해 React 쿼리 또는 SWR 사용
  • Husky와 함께 Commitizen 및 Semantic Release 사용
  • UI 구성 요소 시각화를 위해 스토리북 사용
  • 처음부터 유지 관리 가능한 테스트 작성
  • Dependabot을 사용하여 자동으로 패키지 업데이트
  • Production Checklist | Next.js
#48

React 제네릭 컴포넌트 패턴 - <T extends Record<string, any>>로 row/item 타입 추론

import * as React from 'react'

type Props<TRow> = {
  rows: TRow[]
  renderRow: (row: TRow, index: number) => React.ReactNode
}

const Table = <TRow extends Record<string, any>>({
  rows,
  renderRow,
}: Props<TRow>) => {
  return (
    <table>
      <tbody>{rows.map((row, index) => renderRow(row, index))}</tbody>
    </table>
  )
}

function App() {
  return (
    <Table
      rows={[{ name: 'lee' }]}
      renderRow={(row, index) => (
        <tr key={index}>
          <td>{row.name}</td>
        </tr>
      )}
    />
  )
}

row.name 이 되는 이유 — TS 는 Props<TRow> 와 넘긴 값을 나란히 놓고 TRow 를 줍는다.

Props<TRow> 와 넘긴 props 를 칸마다 나란히 놓는다. rows: TRow[] 자리에는 { name: string }[] 이 와서 배열 안의 맨몸 TRow 자리에서 { name: string } 을 후보로 줍는다. renderRow 의 row 는 타입을 안 적었으니 주울 게 없다. extends Record<string, any> 는 넘긴 값이 아니라 후보가 아니고 검사에만 쓰인다. 후보로 TRow = { name: string } 이 정해지면 그 타입이 거꾸로 renderRow 의 row 로 흘러가서 row.name 을 쓸 수 있다.

후보가 여럿이면 자리에 따라 고른다.

rows={dogs} 에서는 값을 내주는 쪽(공변) 후보 Dog, renderRow={(row: Animal) => …} 에서는 값을 받는 쪽(반변) 후보 Animal 이 나온다. 고르기는 받는 쪽이 기본으로 이기지만, 내주는 쪽이 더 좁으면(하위 타입이면) 더 정확한 내주는 쪽이 이긴다. Dog 는 Animal 의 하위 타입이라 TRow = Dog.

TRow 를 두 번째로 쓰는 prop 은 NoInfer 로 줍기에서 뺀다 (TS 5.4+).

type Props<TRow> = {
  rows: TRow[]
  renderRow: (row: TRow, index: number) => React.ReactNode
  selectedRow?: NoInfer<TRow>
}

rows={[{ name: ‘lee’ }]} selectedRow={{ name: ‘kim’, age: 1 }} 를 넘긴다. NoInfer 가 없으면 두 자리 모두에서 후보를 줍고, 둘 다 객체 리터럴이라 union 으로 합쳐져 TRow 가 넓어지고 에러가 나지 않는다. NoInfer 가 있으면 selectedRow 는 후보로 줍지 않아 TRow = { name: string } 이 되고, selectedRow 를 그 타입으로 검사하다 age 에서 에러가 난다.

extends Record<string, any> 는 원시값만 막는다.

Record<string, any> 는 { [key: string]: any } 로 펼쳐진다. 값이 any 인 인덱스 시그니처는 원시값만 빼고 다 받아서 type 별칭 객체·interface·배열·함수가 통과하고 원시값만 막힌다. Record<string, unknown> 은 암묵적 인덱스 시그니처가 있는 type 별칭 객체만 통과한다 — interface 는 선언 병합으로 속성이 늘 수 있어 암묵적 인덱스 시그니처를 받지 못하고, 배열·함수도 막힌다. object 는 원시값만 막는다.

추론 규칙 전체는 589.

import * as React from 'react'
import { UseComboboxProps, useCombobox } from 'downshift'

function Combobox<T extends Record<string, any>>(
  props: UseComboboxProps<T> & {
    renderItem: (item: T) => React.ReactNode
  }
) {
  const combobox = useCombobox(props)

  return (
    <div>
      <input
        placeholder="구성원을 검색해주세요."
        {...combobox.getInputProps()}
      />
      <div {...combobox.getMenuProps()}>
        {combobox.isOpen &&
          props.items.map((item, index) => (
            <div key={item.id} {...combobox.getItemProps({ item, index })}>
              {props.renderItem(item)}
            </div>
          ))}
      </div>
    </div>
  )
}

function App() {
  return (
    <Combobox
      items={[{ id: 1, name: 'eunsoo' }]}
      itemToString={(item) => `${item?.id}`}
      renderItem={(item) => {
        return <div>{item.name}</div>
      }}
      onInputValueChange={({ inputValue }) => {
        console.log(inputValue)
      }}
    />
  )
}
#480

React 19 Concurrent 훅 - useTransition, useOptimistic vs React Query

useTransition

const [isPending, startTransition] = useTransition()

function handleFilter(value: string) {
  startTransition(async () => {
    const data = await fetchData(value)
    setResults(data)
  })
}
  • startTransition으로 감싼 state 업데이트는 **비긴급(non-urgent)**으로 처리
  • 급한 업데이트(타이핑, 클릭 피드백)를 먼저 처리하고, transition 작업은 뒤로 미룸
  • isPending으로 로딩 상태 확인, 기존 UI 유지하면서 백그라운드에서 새 UI 준비

useOptimistic

const [optimisticItems, addOptimistic] = useOptimistic(
  items,
  (current, newItem) => [...current, newItem]
)

async function handleAdd(item: Item) {
  addOptimistic(item) // 즉시 UI 반영
  await saveToServer(item) // 실패하면 자동 rollback
}
  • 서버 응답 전에 UI 먼저 업데이트, 실패 시 자동 복구
  • 좋아요 버튼, 장바구니 추가 같은 인터랙션에 적합

현실: Query가 이미 너무 편함

const { data, isPending } = useQuery({
  queryKey: ['items', filter],
  queryFn: () => fetchItems(filter),
})

useMutation({
  mutationFn: addItem,
  onMutate: async (newItem) => {
    const previous = queryClient.getQueryData(['items'])
    queryClient.setQueryData(['items'], (old) => [...old, newItem])
    return { previous }
  },
  onError: (err, _, context) => {
    queryClient.setQueryData(['items'], context.previous)
  },
})

캐싱, 리페치, devtools, stale-while-revalidate까지 한 방에 해결. 팀에서 이미 쓰고 있으면 “굳이?” 됨.

useTransition이 의미 있는 지점 - 무거운 클라이언트 연산, Next.js Server Actions 조합

<form action={(formData) => {
  startTransition(async () => {
    await createItem(formData)
    router.refresh()
  })
}}>
#495

React Children API - 가능하지만 비추천

암묵적 의존성, 타입 안전성 부족, 매 렌더 트리 순회, 예측 불가능

재귀 순회

function traverseReactNode(children: ReactNode, callback, typeToMatch?) {
  Children.forEach(children, (child) => {
    if (!isValidElement(child)) return
    if (child.type === Fragment) {
      traverseReactNode(child.props.children, callback, typeToMatch)
      return
    }
    if (child.type === typeToMatch) callback(child)
    if (child.props?.children) {
      traverseReactNode(child.props.children, callback, typeToMatch)
    }
  })
}

동적 래핑

const renderChildren = (children) => {
  const elements = React.Children.toArray(children)
  const hasLink = elements.some(
    (el) => React.isValidElement(el) && el.props.url
  )
  return hasLink ? children : <ul>{children}</ul>
}
// toArray는 string, number도 포함 → isValidElement 체크 필수

대안: Compound Component

// ❌ 마법처럼 동작 (예측 불가)
<Tabs>{/* 어디에 넣든 Tab 찾아줌 */}</Tabs>

// ✅ Compound Component
<Tabs.Root>
  <Tabs.List>
    <Tabs.Trigger value="a">A</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="a">Content</Tabs.Content>
</Tabs.Root>

React 팀도 2021년부터 Children API 사용 권장하지 않음: “Using Children is uncommon and can lead to fragile code”

역사적 배경: 2013년엔 Context API도 없었음. “선언형”이라면서 Children API로 명령형 트리 순회 제공하는 이중성.


#496

React에서 iframe 내부에 컴포넌트 렌더링

// react-frame-component 사용 (권장)
import Frame from 'react-frame-component'
;<Frame head={<style>{`body { margin: 0; }`}</style>}>
  <MyComponent />
</Frame>

// 직접 구현: createPortal + contentDocument
function IframeRenderer({ children }) {
  const iframeRef = useRef<HTMLIFrameElement>(null)
  const [mountNode, setMountNode] = useState<HTMLElement | null>(null)
  useEffect(() => {
    const iframe = iframeRef.current
    const handleLoad = () => setMountNode(iframe?.contentDocument?.body ?? null)
    iframe?.addEventListener('load', handleLoad)
    if (iframe?.contentDocument?.readyState === 'complete') handleLoad()
    return () => iframe?.removeEventListener('load', handleLoad)
  }, [])
  return (
    <>
      <iframe ref={iframeRef} />
      {mountNode && createPortal(children, mountNode)}
    </>
  )
}

iframe 내부는 부모 CSS 미적용 (스타일 별도 주입 필요), 이벤트 버블링 안 됨. 단순 스타일 격리 목적이면 Shadow DOM 고려.

#506

대용량 리스트에서 selected 아이템 조회 최적화

O(n×m) → O(n+m)로 개선: Map으로 인덱싱

// 기존: 매번 find (느림)
selected.map((key) => items.find((item) => item.key === key))

// 개선: Map 인덱싱 (빠름)
const itemsMap = new Map(items.map((item) => [item.key, item]))
selected.map((key) => itemsMap.get(key)).filter(Boolean)

React에서 백그라운드 인덱싱:

function useItemsIndex(items: Item[]) {
  const [map, setMap] = useState(new Map())
  const [ready, setReady] = useState(false)

  useEffect(() => {
    // 청크 단위로 처리하여 UI 블로킹 방지
    const newMap = new Map(items.map((item) => [item.key, item]))
    setMap(newMap)
    setReady(true)
  }, [items])

  return { map, ready }
}

10만개 이상이면 Web Worker 고려.

#508

RSC에서 날짜 처리: 쿠키 기반 타임존

문제: Date 객체 직렬화, 서버/클라이언트 타임존 불일치, 하이드레이션 FOUT

해결: 서버에서 타임존 적용해서 렌더링

// middleware.js - 타임존 감지
export function middleware(request) {
  const timezone =
    request.cookies.get('user-timezone')?.value ||
    request.geo?.timezone || // Vercel
    'UTC'

  const response = NextResponse.next()
  response.headers.set('x-user-timezone', timezone)
  return response
}

// 서버 컴포넌트
const getUserTimezone = cache(() => {
  return headers().get('x-user-timezone') || 'UTC'
})

// 클라이언트 - 타임존 자동 감지 후 쿠키 저장
useEffect(() => {
  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
  document.cookie = `user-timezone=${tz}; path=/; max-age=31536000`
}, [])

첫 방문은 인프라 추정값 사용, 이후 정확한 타임존 적용. FOUT 없음.


대안: useSyncExternalStore (client component 전용)

'use client'

const timezoneStore = {
  getSnapshot: () => Intl.DateTimeFormat().resolvedOptions().timeZone,
  getServerSnapshot: () => 'UTC',
  subscribe: () => () => {},
}

function useTimezone() {
  return useSyncExternalStore(
    timezoneStore.subscribe,
    timezoneStore.getSnapshot,
    timezoneStore.getServerSnapshot
  )
}

서버: UTC → 클라이언트: 실제 타임존. 하이드레이션 에러 없음, 대신 FOUT 발생.

#509

RTL에서 overflow scroll 상태 테스트

// scrollWidth > clientWidth면 가로 스크롤 필요
const isOverflowScrollable = (el) => ({
  horizontal: el.scrollWidth > el.clientWidth,
  vertical: el.scrollHeight > el.clientHeight,
})

test('overflow 확인', () => {
  render(<ScrollableComponent parentWidth={300} childWidth={500} />)

  const parent = screen.getByTestId('parent-container')

  expect(isOverflowScrollable(parent).horizontal).toBe(true)
})

test('동적 크기 변경', () => {
  const { rerender } = render(<Comp parentWidth={400} childWidth={300} />)

  expect(isOverflowScrollable(screen.getByTestId('parent')).horizontal).toBe(
    false
  )

  rerender(<Comp parentWidth={400} childWidth={600} />)

  expect(isOverflowScrollable(screen.getByTestId('parent')).horizontal).toBe(
    true
  )
})

DOM 완전 렌더링 후 측정해야 정확. getBoundingClientRect()는 실제 크기 반환.

#521

react-is로 children에서 특정 컴포넌트 필터링

react-is는 React 공식 패키지로, React 요소 타입 확인 유틸리티. typeof나 instanceof로는 React.memo(), forwardRef() 등을 구분할 수 없어서 필요함. 라이브러리 개발, children 타입 검사에 필수.

import * as ReactIs from 'react-is'

// 특정 컴포넌트 타입인지 확인
function isComponentType<T>(element: ReactNode, Type: ComponentType<T>) {
  return ReactIs.isElement(element) && element.type === Type
}

// children에서 특정 컴포넌트만 필터링
function filterChildren<T>(children: ReactNode, Type: ComponentType<T>) {
  return Children.toArray(children).filter(
    (child): child is ReactElement<T> =>
      ReactIs.isElement(child) && child.type === Type
  )
}

// 사용: Layout에서 Header, Content, Footer 분리
const Layout = ({ children }) => {
  const headers = filterChildren(children, Header)
  const contents = filterChildren(children, Content)

  return (
    <div>
      <div className="header">{headers}</div>
      <div className="content">{contents}</div>
    </div>
  )
}
#528

컴포넌트가 렌더링하는 요소를 외부에서 제어하는 세 가지 패턴.

as: 소비자가 태그 이름(as=“a”)을 넘기면 Button 이 Comp = as 로 그 태그를 렌더하고 props 를 그대로 펼친다 — 단순 태그 변경에 쓴다. asChild: 소비자가 자식 요소(<a href />)를 넘기면 Button 이 Slot 으로 그 자식을 cloneElement 해 className 병합·핸들러 합성·ref 병합을 암묵적으로 한다 — 기존 컴포넌트 합성에 쓴다. render: 소비자가 render 에 요소나 (props, state) => 함수를 넘기면 Button 이 ref 포함 DOM props 와 isPressed 같은 상태를 명시적으로 건네고 소비자가 직접 펼친다 — 상태 기반 커스터마이징과 합성에 쓰고 타입 추론이 가장 좋다.

as

type PolymorphicProps<E extends React.ElementType> = {
  as?: E
} & React.ComponentPropsWithoutRef<E>

function Button<T extends React.ElementType = 'button'>({
  as,
  ...props
}: PolymorphicProps<T>) {
  const Comp = as || 'button'
  return <Comp {...props} />
}

asChild + Slot

import { Slot } from '@radix-ui/react-slot'

function Button({ asChild, ...props }) {
  const Comp = asChild ? Slot : 'button'
  return <Comp {...props} />
}

Slot 직접 구현:

const Slot = forwardRef(({ children, ...props }, ref) => {
  if (!isValidElement(children)) return null

  return cloneElement(children, {
    ...props,
    ...children.props,
    ref: ref || children.ref,
  })
})

자식 요소 타입에 따른 조건부 Props:

type ConditionalProps<T> = T extends ReactElement<any, 'a'>
  ? { href?: string; external?: boolean }
  : T extends ReactElement<any, 'button'>
    ? { type?: 'button' | 'submit' }
    : {}

render

// Element 방식
<Button render={<a href="/about" />}>Link</Button>

// Callback 방식 - state 접근 가능
<Button render={(props, state) => (
  <motion.button {...props} animate={state.isPressed ? ... : ...} />
)}>
  Animated
</Button>

#538

isLoading 없이, onClick 에서 yield 만 한다. 핸들러를 async generator 로 받아 훅이 for await 로 돌며 yield 할 때마다 값을 바꾼다 — setLoading → setProgress → setLoading(false) 가 시간 순서대로 한 함수에 들어간다.

useGenerator 의 상태는 useReducer 하나다. idle 에서 start 로 running 이 되고(에러도 함께 비운다), yield 는 status 를 두고 value 만 바꾸며, generator 가 끝나면 done, 본문 어디서든 throw 하면 catch 에서 error 로 errored 다. cancel 은 abort 하고 idle 로 되돌린다 — 안 하면 running 에 멈춘다. unmount 되면 cleanup 이 abort 하고, 기다리던 await wait 가 reject 돼 throw 가 나지만 aborted 라 에러로 치지 않는다. 같은 훅을 쓰는 쪽이 재진입 정책을 고른다: LoadingButton 은 running 동안 disabled 로 다시 못 누르게 하고 JSX 를 yield 하면 그대로 라벨이 된다. 검색은 run(q) 마다 직전 실행을 abort 하고 다시 시작해, a 다음 ab 를 치면 a 의 remote 결과가 끼어들지 않는다.

import { useEffect, useReducer, useRef } from 'react'

type Status = 'idle' | 'running' | 'errored' | 'done'

type State<T> = { status: Status; value: T; error: unknown }

type Action<T> =
  | { type: 'start' }
  | { type: 'yield'; value: T }
  | { type: 'done' }
  | { type: 'error'; error: unknown }
  | { type: 'cancel' }

function reducer<T>(state: State<T>, action: Action<T>): State<T> {
  switch (action.type) {
    case 'start':
      return { ...state, status: 'running', error: null } // 상태와 에러 초기화가 한 번에 — 따로 set 하다 어긋날 일이 없다
    case 'yield':
      return { ...state, value: action.value }
    case 'done':
      return { ...state, status: 'done' }
    case 'error':
      return { ...state, status: 'errored', error: action.error }
    case 'cancel':
      return { ...state, status: 'idle' } // 없으면 'running' 에 멈춰 있다
  }
}

function useGenerator<T, A extends unknown[]>(
  fn: (signal: AbortSignal, ...args: A) => AsyncGenerator<T>, // signal 은 훅이 첫 인자로 끼워 넣는다
  initial: T,
) {
  const [state, dispatch] = useReducer(reducer<T>, {
    status: 'idle',
    value: initial,
    error: null,
  })
  const ctrlRef = useRef<AbortController | null>(null)

  useEffect(() => () => ctrlRef.current?.abort(), []) // unmount 되면 진행 중인 흐름을 끊는다

  const run = async (...args: A) => {
    // 매 렌더 새로 만들어져 항상 최신 fn 을 읽는다
    ctrlRef.current?.abort() // 재진입 — 직전 실행을 끊고 다시 시작
    const ctrl = new AbortController()
    ctrlRef.current = ctrl
    dispatch({ type: 'start' })
    try {
      for await (const value of fn(ctrl.signal, ...args)) {
        if (ctrl.signal.aborted) return
        dispatch({ type: 'yield', value }) // yield 마다 리렌더
      }
      dispatch({ type: 'done' })
    } catch (error) {
      if (ctrl.signal.aborted) return // abort 로 생긴 throw 는 에러가 아니다
      dispatch({ type: 'error', error })
    }
  }

  const cancel = () => {
    ctrlRef.current?.abort()
    dispatch({ type: 'cancel' })
  }

  return { ...state, run, cancel }
}

검색은 같은 훅에서 재진입이 재시작이 된다 — 새 입력이 들어오면 이전 tier 가 끊긴다.

async function* searchTiers(signal: AbortSignal, q: string): AsyncGenerator<string[]> {
  await wait(100, signal); yield [`${q}:local`]
  await wait(300, signal); yield [`${q}:local`, `${q}:remote`]
}

const { value, run } = useGenerator(searchTiers, [])
<input onChange={(e) => run(e.target.value)} />

0ms 에 a 를 치면 run(a) 가 시작해 100ms 대기 뒤 a:local 을 yield 하고, 300ms 더 기다려 400ms 에 a:remote 를 낼 참이다. 그런데 150ms 에 ab 를 치면 run(ab) 가 직전 실행을 abort 하고, a 가 기다리던 wait 가 reject 돼 그 자리에서 멈춘다 — a:remote 는 오지 않는다. run(ab) 는 250ms 에 ab:local, 550ms 에 ab:local,ab:remote 를 내고 done 이 된다. 화면에는 a:local → ab:local → ab:local,ab:remote 순서로만 보인다. tier 경계(await wait)가 그대로 취소 지점이라 debounce 가 필요 없다.

언제 — 시간이 코드의 한 방향(↓)으로만 흐를 때: 멀티 단계 loading · progressive search(tier 경계 = 취소 지점, debounce 불필요) · optimistic mutation(optimistic | reconciled | rolledBack 전이를 콜백 넷 대신 한 함수로).

확인 — 원본 데모 대비 채운 셋: ERRORED 를 선언만 하고 안 쓰던 것(→ catch), unmount 시 취소(→ AbortSignal + cleanup), 타입(→ 제네릭 훅). 검색창에 a 다음 바로 ab 를 치면 a:remote 가 끼어들지 않고 ab:local → ab:local,ab:remote 로만 바뀌면 된 것이다.


#540

바깥 클릭 감지를 useEffect 없이 ref callback 하나로 만든다.

React 19 이상에서만 된다.

function useClickOutside(handler) {
  const handlerRef = useRef(handler)
  handlerRef.current = handler // stale closure 방지

  return useCallback((node) => {
    const handleClick = (e) => {
      if (!node.contains(e.target)) handlerRef.current()
    }

    document.addEventListener('mousedown', handleClick)
    return () => document.removeEventListener('mousedown', handleClick)
  }, [])
}

// 사용
function Modal({ onClose }) {
  const ref = useClickOutside(onClose)
  return <div ref={ref}>...</div>
}

모달을 열기 → 닫기 → 열기 → 닫기 → 열기 한 뒤 바깥을 클릭한다. React 19: 열 때 ref(node) 가 mousedown 리스너를 추가하고, 닫을 때 반환한 cleanup 이 제거해 남는 리스너가 1 → 0 → 1 → 0 → 1 이다. 바깥 클릭에 handler 가 한 번만 불린다. React 18 이하: 반환한 cleanup 은 무시되고, 닫을 때 같은 콜백이 ref(null) 로 다시 불려 본문이 또 돈다 — node 가 null 인 리스너가 하나 더 붙는다. 남는 리스너가 1 → 2 → 3 → 4 → 5 로 쌓이고, 바깥 클릭에 handler 가 세 번 불리고 null.contains 에서 TypeError 가 두 번 난다. 18 이하는 리스너를 별도 ref 에 저장하거나 useEffect 를 쓴다.

참고

dnd-kit drop 직후 튕김 — setQueryData 는 화면을 한 틱 늦게 바꾼다

dnd-kit Sortable + react-query에서 drop 직후 아이템이 원래 자리로 돌아갔다가 새 자리로 점프한다. 1~2프레임짜리 튕김이고 onDragEnd에서 setQueryData로 즉시 캐시를 갱신해도 그대로다.

① 최소 재현: Sortable 리스트에 useQuery 를 붙이고 CDP Input.dispatchMouseEvent 로 드래그한다. ② 숫자로 잡기: requestAnimationFrame 마다 DragOverlay·아이템의 top 을 기록하는 프로브를 먼저 걸고, drop 뒤 최종 배치와 다른 프레임이 있으면 튕김이다. ③ 가설을 하나씩 끈다: dropAnimation={null} 로 사라지면 drop 애니메이션이 즉시 재는 탓, setState 와 setQueryData 를 번갈아 돌려 후자만 튕기면 캐시 알림 경로, notifyManager.setScheduler(queueMicrotask) 로 사라지면 원인 확정. ④ 고친다: 렌더는 local state, 캐시 갱신은 부수효과로. ⑤ 회귀 확인: ② 와 같은 프로브를 refetch 뒤까지 돌린다. 다시 튕기면 refetch 가 옛 순서를 덮음 → isReorderingRef 가드, id 흔들림 → stable id, DragOverlay 만 남음 → dropAnimation={null}.

③에서 확정되는 원인 — drop 애니메이션은 놓는 즉시 목적지를 재는데1, setQueryData의 리렌더는 notifyManager의 setTimeout(0)을 거쳐 한 틱 늦다.2

drop 하면 onDragEnd 가 돌고, dnd-kit 의 drop 애니메이션은 곧바로(ASAP) 목적지를 잰다. setState 로 바꾸면 그 이벤트 처리 안에서 새 순서가 렌더되어, 잴 때 이미 새 자리라 새 자리로 애니메이션한다. setQueryData 로 바꾸면 캐시는 즉시 바뀌지만 구독자 알림은 notifyManager 의 기본 스케줄러 setTimeout(0) 을 거쳐 다음 매크로태스크에 리렌더된다. 그 사이 dnd-kit 이 옛 순서를 재서 옛 자리로 애니메이션하고, 리렌더 순간 새 자리로 점프한다 — 1~2프레임 튕김. 캐시는 바로, 화면은 한 틱 뒤다. 눈금은 비례가 아니다.

④의 코드:

function SortableList() {
  const { data: serverItems = [] } = useQuery({
    queryKey: ['items'],
    queryFn: fetchItems,
  })
  const [items, setItems] = useState<Item[]>(serverItems)

  const isReorderingRef = useRef(false)
  useEffect(() => {
    if (!isReorderingRef.current) setItems(serverItems)
  }, [serverItems])

  const reorderMutation = useMutation({
    mutationFn: reorderItems,
    onSettled: () => {
      isReorderingRef.current = false
      queryClient.invalidateQueries({ queryKey: ['items'] })
    },
  })

  const handleDragEnd = ({ active, over }: DragEndEvent) => {
    if (!over || active.id === over.id) return
    const next = arrayMove(items, idxOf(active.id), idxOf(over.id))
    isReorderingRef.current = true
    setItems(next)
    reorderMutation.mutate(next)
  }
  // <DndContext onDragEnd={handleDragEnd}> <SortableContext items={items.map(i=>i.id)}> ...
}

라이브러리가 렌더 직후 DOM을 재면, 기준은 상태가 바뀌는 시점이 아니라 화면이 바뀌는 시점이다.

v6.3.1 기준. v10+는 OptimisticSortingPlugin이 기본 활성화라 기제가 다르다 → 재검증 필요.

Footnotes

  1. “the drop animation for DragOverlay measures the destination position ASAP, and so it catches the 1 or 2 frames before my reorder is applied, and the item animates back to its original position.” (dnd-kit #833) ↩

  2. notifyManager.schedule 은 “schedules a function to be run on the next batch. By default, the batch is run with a setTimeout” — setScheduler 로 queueMicrotask·requestAnimationFrame 으로 바꿀 수 있다. (notifyManager — TanStack Query) ↩

Fiber 가 렌더링을 쪼갤 수 있는 이유 — 콜 스택 대신 세 포인터

#560

React 밖 상태를 useSyncExternalStore로 구독하기

class ToastManager extends EventTarget {
  get toasts() {
    return this._toasts // 새로 만들지 않는다 — 같은 참조면 안 바뀐 것
  }
  set toasts(value) {
    this._toasts = [...value] // 새 참조 = "바뀌었다"는 신호
    this.dispatchEvent(new Event('changed'))
  }
  // add·remove … 는 전부 이 setter 를 거친다 — 변경과 발행이 한 곳이라 발행 누락이 없다
}
const subscribe = (cb) => {
  toastManager.addEventListener('changed', cb)
  return () => toastManager.removeEventListener('changed', cb)
}

// useEffect + useState 로 손으로 복사하던 동기화를 React 가 맡는다.
// changed 마다 getSnapshot 을 불러 참조를 비교하고, 다를 때만 리렌더한다.
const toasts = useSyncExternalStore(subscribe, () => toastManager.toasts)

어디서든 add 를 부르면 setter 가 새 배열을 만들고 changed 를 발행한다. React 는 getSnapshot 으로 다시 읽어 이전 값과 Object.is 로 비교해 다르면 리렌더, 같으면 건너뛴다. getter 는 내부 배열을 그대로 돌려주므로 안 바뀌면 같은 참조다. getSnapshot 이 매번 새 배열을 만들면 항상 달라 무한 루프가 된다.


#563

로딩 스피너 깜빡임의 두 끝 — 앞쪽은 CSS, 뒤쪽은 JS

스피너가 한 프레임 번쩍하고 사라지거나, 떴다가 하드컷으로 끊긴다. 깜빡임은 양 끝에서 따로 생기는데 CSS는 한쪽만 잡는다. 어느 끝인지는 응답 시각을 붙잡고 보인 시간을 재서 가른다 — DOM에 붙어 있던 시간이 아니다.

요청 뒤 CSS 지연이 지나야 스피너가 뜬다. 앞쪽: 응답이 지연보다 먼저 오면 스피너는 한 프레임도 안 뜬다 — 지연이 통째로 건너뛰게 한다. 뒤쪽, CSS 만: 지연 뒤에 뜬 스피너가 곧바로 온 응답에 끊겨 짧게 번쩍하고 하드컷된다. 뒤쪽, useMinimumLoading: 응답이 와도 unmount 를 밀어 최소 노출 시간만큼은 보이게 한다. CSS 는 시작(지연)만 정하고, 끝은 응답 시각이 정하는데 CSS 는 그 값을 모른다.

흐름

Claude Code 의 node 스크립트가 Chrome(CDP)에 Fetch.enable 로 */api 를 가로채게 하고, 경계값을 볼 때는 Tracing.start 로 스크린샷도 켠다. hold 를 100·350·700 으로 바꿔 가며 반복한다: 프로브 evaluate 를 기다리지 않고 보낸 뒤 console.timeStamp 와 클릭을 보낸다. 페이지에 스피너가 붙고 fetch /api 가 나가면 Chrome 이 Fetch.requestPaused 를 보내고, 스크립트는 holdMs 를 기다린 뒤 Fetch.continueRequest 로 놓아준다. 요청이 서버에 가고 응답이 오면 스피너가 제거되고 프로브가 firstVisibleAt·visibleFrames·visibleMs 를 JSON 으로 돌려준다. 끝나면 Tracing.end 로 Screenshot 이벤트를 받는다 — 처음 물든 장부터 다음 흰 장까지가 실제로 보인 시간이고, 프로브는 1프레임쯤 더 센다.

  1. headless Chrome + Node 전역 WebSocket으로 raw CDP. MCP emulate는 프리셋 스로틀링뿐이라 요청 하나를 못 붙잡는다.
  2. 프로브 — 스피너가 사라질 때 보인 시간을 돌려준다.
  3. 경계값이면 disabled-by-default-devtools.screenshot trace로 확인한다. 스크린샷은 바뀐 프레임에만 찍히니 처음 물든 장 → 다음 흰 장이 보인 시간이다.
  4. 응답 < 지연인데 보였으면 앞쪽, 응답 > 지연인데 최소 노출보다 짧으면 뒤쪽.
지연 응답 최소 노출 프로브 trace 판정
0 100 — 9프레임 · 133ms 앞쪽
300 100 — 0 물든 장 없음 앞쪽 억제됨
300 350 — 4프레임 · 66ms 약 48ms, 불투명도 최대 약 0.8 뒤쪽
300 350 500 515ms 뒤쪽 잡힘
300 100 500 0 앞쪽 억제 유지

실측 조건은 각주에 있다.1 셋째 줄이 뒤쪽 깜빡임의 실제 모습이다 — 페이드가 끝나기 전에 끊겨 한 번도 제대로 진해지지 못하고 사라진다.

처방

  • 앞쪽 — animation-delay/transition-delay. 응답이 지연보다 빠르면 스피너를 통째로 건너뛴다. @starting-style 단독(지연 0)으로는 못 막는다.
  • 뒤쪽 — CSS는 시작만 통제하고 끝은 CSS가 모르는 응답 시각이 정하니, 사라지는 시각을 JS에서 민다(useMinimumLoading). 최소 노출은 처음 보인 시각부터 잰다: 사라지는 시각 = max(응답 시각, firstVisibleAt + 최소 노출). 요청 시점부터 재면 표 마지막 줄에서 안 떠도 될 스피너를 붙잡는다.

밟은 것

  • spawn한 프로세스만 죽이면 Chrome 자식이 남아 다음 실행이 옛 페이지에 붙는다 → detached: true + process.kill(-pid).
  • 옛 페이지에 붙은 채 Runtime.enable하면 이전 console 메시지가 다시 온다.
  • 포트가 뜨기 전 /json은 ECONNREFUSED → 폴링.
  • 첫 측정은 느리다(hold 100에 응답 149ms) → 첫 줄은 다시 잰다.

곁가지 — @starting-style은 같은 뿌리의 반대편이다. transition은 두 상태 사이를 보간하는데, 진입에는 옮겨갈 이전 상태가 없고2 퇴장에는 요소 자체가 이미 사라진다. @starting-style은 앞쪽의 출발값을 대신 정해주고(진입 fade-in), useMinimumLoading은 뒤쪽의 unmount를 미룬다. 뿌리는 같아도 손대는 지점이 달라 별개 결정이다.

Footnotes

  1. 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) → 흰색. ↩

  2. CSS transition은 요소의 첫 스타일 적용이나 display: none → 표시 전환에서는 기본적으로 발동하지 않는다. @starting-style이 “무엇에서 출발할지”를 정의해 그걸 가능하게 한다. (@starting-style — MDN) ↩

#573

Suspense는 비동기를 동기처럼 보이게 만든다. 데이터가 아직 없으면 promise를 던지고, React가 그걸 받아 기다렸다가 다시 그린다. 그래서 상태를 조율하는 대신 입력을 미루고 → 질의하고 → 그리는 순서로 끝난다.

언제: 비동기 데이터를 그리는데 로딩·에러가 늘면서 화면이 튈 때. 또는 새로 만들면서 어느 쪽으로 갈지 고를 때.

각각 들고 있으면: loading 과 error 가 따로 켜지고 꺼져 네 칸이 생긴다 — 둘 다 꺼짐은 성공, error 만 켜짐은 에러, loading 만 켜짐은 로딩, 둘 다 켜짐은 있을 수 없는 “로딩 + 에러”인데도 적을 수 있다. 그 칸이 한 프레임이라도 그려지면 화면이 튄다. 트리로 옮기면: 로딩(Suspense) → resolve → 성공(본문), 로딩 → reject → 에러(ErrorBoundary), 성공 → 입력 변경으로 promise 를 던지면 → 로딩. 상태는 셋 중 하나라 넷째 칸을 적을 수가 없다.

절차

  1. 검색어·결과·로딩 중인지·에러인지를 각각 들고 있는지 본다.
  2. 분기를 if/else에서 트리로 옮긴다 — 로딩은 <Suspense>, 에러는 <ErrorBoundary>, 성공은 본문.
  3. 본문에 loading·error를 인자로 넘기지 않는다. 안 넘겨도 가장 가까운 경계가 대신 잡기 때문이다 — render는 결과 하나만 받는다.

확인: 있을 수 없는 조합을 적을 수 있는가. 적을 수 없으면 된 것이다.

함정: 스피너가 너무 빨리 뜨고 사라지는 건 이 축으로 안 고쳐진다 — 다른 축이다 → 573


곁가지 — 인자를 위로 들어올려 바깥이 처리하게 하는 이 모양은 대수적 효과(algebraic effects)의 handler와 같다.1

stale-while-revalidate(두 시계·isStale) 부분은 570으로.

Footnotes

  1. try/catch와 비슷하다 — 중간 함수들은 몰라도 되고 가장 가까운 handler가 받는다. 다른 점은 처리하고 끝나지 않는다는 것이다. handler가 값을 돌려주면 효과를 일으킨 자리로 되돌아가 실행이 이어진다. (Algebraic Effects for the Rest of Us) ↩

#575

useMemo 캐시 적중 진단 — 매 렌더 새로 만들어지는 deps

무거운 계산을 useMemo로 감쌌는데 렌더 시간이 줄지 않는다. 각 단계는 질문 하나에 답하고, 그 답이 다음 질문을 만든다. 단계마다 추론이 아니라 관찰로 넘어간다.

1 렌더가 느린가(Profiler ms) → 2 재사용되나(calc 횟수) → 3 왜 다시 계산하나(Object.is 로 참조 비교) → 4 누가 새 참조를 만드나(부모로 거슬러 올라가 useMemo) → 5 고쳐졌나(같은 조작으로 횟수와 ms 비교). 1 에서 병목이 아니면 멈춘다. 5 에서 둘 다 줄었으면 해결이고, 횟수는 줄었는데 ms 가 그대로면 병목이 다른 곳이라 1 로 돌아간다. 6 값이 틀리다면 느린 게 아니라 별개 축이다 — stale closure 는 exhaustive-deps 로, 비결정 계산은 두 번 호출 결과 비교나 HMR 로 확인한다.

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

  1. “React will compare each dependency with its previous value using the Object.is comparison.” / “React will not throw away the cached value unless there is a specific reason to do that … This should be fine if you rely on useMemo solely 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

  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

#578

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

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

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

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

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 으로

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

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

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

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

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

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

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

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

컴포넌트가 마운트된 동안 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()
  }}
/>
#594