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

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


#193

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

#203

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

react-prismazoom은 CSS 변환을 사용하여 React에서 확대 및 이동 기능을 제공하는 팬 및 줌 컴포넌트입니다. 이 라이브러리는 prop-types, react, react-dom 모듈에만 의존하며, 데스크톱 및 모바일에서 모두 작동합니다.

주요 기능 및 특징

  • 확대 기능 : 마우스 휠이나 두 손가락으로 확대할 수 있습니다. 더블 클릭 또는 더블 탭을 사용하여 확대할 수도 있으며, 선택한 영역을 확대하여 중앙에 배치할 수 있습니다.
  • 이동 기능 : 마우스 포인터나 줌 인 상태에서 손가락을 사용하여 이동할 수 있습니다. 확대된 상태에서는 사용 가능한 공간에 따라 직관적으로 이동합니다. 요소를 이동할 수 있는 방향을 나타내기 위해 커서 스타일을 조정합니다.

그 외 비슷한

#269
const root = createRoot('#confirm-root')

root.render(<Confirm />)

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

#270
if (!data) {
  throw fetch()
}
  1. 컴포넌트가 렌더링될 때, 비동기 작업(예: 데이터 패칭)이 시작됩니다. 이 작업은 일반적으로 promise를 반환합니다.
  2. 비동기 작업이 완료되지 않은 경우, 컴포넌트는 promise를 던집니다. 이는 JavaScript에서 예외를 던지는 것과 유사합니다. Suspense는 promise가 던져질 때 이를 캐치하고 fallback UI를 표시하는 역할을 합니다.
  3. React는 컴포넌트가 promise를 던졌을 때 이를 감지하고, Suspense 컴포넌트에서 이를 “캐치”합니다. Suspense는 이 promise가 해결될 때까지 대체 UI (fallback)를 렌더링합니다. Concurrent Mode에서는 React가 이 promise를 추적하고, 비동기 작업이 완료될 때까지 렌더링을 중단합니다.
  4. Promise가 해결되면(즉, 비동기 작업이 완료되면) React는 컴포넌트를 다시 렌더링합니다. Suspense는 현재 데이터 패칭 라이브러리(예: React Query, SWR)와 함께 사용되어 비동기 작업의 상태를 쉽게 관리할 수 있도록 도와줍니다.

#307

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을 통해 조건을 받아들이고, 자식으로 ThenElse를 받아 각각의 내용을 렌더링하도록 한다.

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
}

그렇다면 MapSet도 시도해보기

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을 사용하여 자동으로 패키지 업데이트
  • Going to Production | 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>
      )}
    />
  )
}
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 - 가능하지만 비추천

WARNING

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

재귀 순회

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)}
    </>
  )
}

WARNING

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
  )
})

CAUTION

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

#521

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

NOTE

react-is는 React 공식 패키지로, React 요소 타입 확인 유틸리티. typeofinstanceof로는 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

태그명을 props로 전달. 가장 단순하지만 타입이 복잡해짐.

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

Radix UI 방식. 자식 요소로 렌더링 위임. prop 병합이 암묵적.

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

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

Slot이 자동으로 처리하는 것: className 병합, 이벤트 핸들러 합성, ref 병합.

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

Base UI, React Aria 방식. 명시적 prop 전달. 타입 추론이 가장 좋음.

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

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

render prop은 (domProps, renderProps) 시그니처. domProps는 ref 포함 DOM 속성, renderProps는 컴포넌트 상태(isPressed, isSelected 등).

선택 기준

| 패턴    | 복잡도 | 타입 안전성 | 유연성 |
| ------- | ------ | ----------- | ------ |
| as      | 낮음   | 보통        | 낮음   |
| asChild | 중간   | 보통        | 높음   |
| render  | 중간   | 높음        | 높음   |
  • 단순 태그 변경 → as
  • 기존 컴포넌트 합성 → asChild 또는 render
  • 상태 기반 커스터마이징 → render

#538

여러 단계의 비동기 UI 업데이트를 setLoading → setProgress → setLoading(false)로 흩뿌리지 않고 async generator의 yield 시퀀스로 시간 순서대로 표현. 원본 트릭은 @ericclemmons.

type FlowEvent<P extends string, D> = { phase: P; data: D }

async function* loadingFlow(
  signal: AbortSignal,
): AsyncGenerator<FlowEvent<'starting' | 'slow' | 'done', string>> {
  yield { phase: 'starting', data: 'Starting…' }
  await wait(1000, signal)
  yield { phase: 'slow', data: 'Taking longer than usual' }
  await wait(2000, signal)
  yield { phase: 'done', data: 'Got it 🎉' }
}

원본의 빈 자리 → 소비 레이어 책임

원본 데모는 4가지가 비어 있음. hook이든 actor든 wrapping 레이어에서 채워야 함:

  • cancellation: generator에 AbortSignal 주입 → await 대상(wait, fetch)이 signal-aware해야 진짜 취소됨
  • 재진입: 새 실행 시 직전 iterator abort
  • error path: generator 내부 throw → 상태 노출
  • unmount/teardown: cleanup으로 in-flight iterator abort
  • stale closure: 최신 클로저 참조 (hook은 ref, actor는 input)
  • typed: phase/data 모두 좁힘

어디에 맞는가

generator는 시간이 코드의 한 방향(↓)으로만 흐르는 모델. 그 모양에 맞는 시나리오:

  • 멀티 단계 loading 메시지 (위 loadingFlow)
  • progressive search — tier별로 점진적 결과 yield, tier 경계가 자연스러운 cancellation point, debounce 불필요
  • optimistic mutation — { phase: 'optimistic' | 'reconciled' | 'rolledBack', data } 전이를 4개 콜백 대신 한 함수로
#540

useClickOutside — ref callback 방식 정리

  • ref callback은 DOM 노드 참조만 전달할 뿐, 내부에서 등록한 이벤트 리스너 등의 사이드이펙트는 자동 해제되지 않음
  • React 18 이하: unmount 시 callback(null) 호출되지만, 동일 함수 참조가 없어 removeEventListener 곤란 → 별도 ref 저장 필요
  • React 19: useEffect처럼 cleanup function return을 공식 지원 → return () => removeEventListener(...) 패턴 사용 가능
  • stale closure 방지는 handlerRef.current = handler 패턴으로 해결
  • 결론: React 19라면 ref callback + cleanup return이 useEffect 방식의 깔끔한 대안
function useClickOutside(handler) {
  const handlerRef = useRef(handler);
  handlerRef.current = handler;

  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>;
}

참고

dnd-kit Sortable + API 연동 시 drop 직후 튕김(flicker) 문제

dnd-kit v6.x + TanStack Query 조합에서 드래그로 순서 변경 후 mutation을 트리거하면, drop 직후 한 프레임 동안 아이템이 원래 위치로 돌아갔다가 새 위치로 점프하는 현상.

증상

  • onDragEnd에서 setQueryData로 캐시를 즉시 업데이트 (낙관적 업데이트)
  • 이론상 동기 업데이트라 즉시 새 순서로 렌더되어야 함
  • 실제로는 drop 시점에 1~2 프레임 동안 원래 위치로 보였다가 새 위치로 settle
  • DragOverlay를 쓰면 더 두드러짐

원인

dnd-kit 자체의 알려진 동작이며, 본인 코드 문제가 아님.

  • dnd-kit#833 — DragOverlay의 drop 애니메이션이 destination position을 ASAP로 측정. reorder가 적용되기 전 1~2프레임을 catch해서 원래 위치로 애니메이션 후 점프
  • dnd-kit#921 — react-query 캐시를 직접 업데이트하면 sorting이 flicker. 의미 없는 React state를 같이 set하면 해결되는 것처럼 보임 → dnd-kit이 drop 애니메이션 flush에 React state lifecycle에 의존한다는 추정
  • dnd-kit Discussions#1522 — v6/v8에서도 동일 증상 재현. 답변자도 결국 별도 local state로 우회

핵심: dnd-kit의 측정 사이클 ↔ React Query 캐시 업데이트 사이클이 불일치. 캐시는 동기 업데이트지만 dnd-kit이 의존하는 commit phase를 타지 않음.

해결 패턴

렌더의 source-of-truth를 캐시가 아니라 local state로 둔다. 캐시 업데이트는 부수 효과(서버 동기화)로 격리.

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

  // 렌더용 source of truth
  const [items, setItems] = useState<Item[]>(serverItems)

  // 서버 데이터가 바뀌면 동기화 (단, 드래그 직후 race 가드)
  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 = (event: DragEndEvent) => {
    const { active, over } = event
    if (!over || active.id === over.id) return

    const oldIndex = items.findIndex((i) => i.id === active.id)
    const newIndex = items.findIndex((i) => i.id === over.id)
    const next = arrayMove(items, oldIndex, newIndex)

    isReorderingRef.current = true
    setItems(next) // 1) local state 먼저 (dnd-kit이 이걸 보고 측정)
    reorderMutation.mutate(next) // 2) 서버 동기화는 별개
  }

  return (
    <DndContext onDragEnd={handleDragEnd}>
      <SortableContext items={items.map((i) => i.id)}>
        {items.map((item) => (
          <SortableItem key={item.id} item={item} />
        ))}
      </SortableContext>
    </DndContext>
  )
}

핵심 포인트

  1. 렌더는 local state, 캐시는 부수 효과setQueryData 결과를 렌더에 직접 쓰면 flicker. setState는 dnd-kit이 의존하는 React commit 사이클을 탐.
  2. invalidate race 방지isReorderingRef 없이는 mutation 직후 refetch가 옛 순서를 들고 와 useEffect가 local state를 덮어씀. 이게 또 다른 튕김으로 보일 수 있음. onSettled에서 플래그 내리고 invalidate.
  3. 서버 응답으로 정렬 결과를 받으면 더 깔끔mutationFn이 새 순서 반환 → onSuccess에서 setItems(data) 직접 동기화하고 invalidate 제거. race 가장 없음.

추가 점검 포인트

Key/ID 안정성

  • SortableContext items={...}, 자식의 key, useSortable({ id }) 모두 같은 stable id 사용
  • 인덱스를 key로 쓰면 dnd-kit과 거의 항상 충돌

DragOverlay 사용 시

  • 위 패턴으로도 잔여 flicker가 있으면 dropAnimation={null} 또는 duration 단축
  • drop animation measure 타이밍이 워낙 빠르므로 (#833) 애니메이션 자체를 줄이는 게 가장 확실
  • 진단용으로 한 번 dropAnimation={null} 적용해서 튕김이 사라지면 #833 케이스 확정

Mutation 응답 처리

  • 백엔드가 update 직후 GET에서 옛 데이터 반환할 가능성이 있으면 mutation 응답 → setQueryData 명시 동기화 권장
  • invalidateQueries만 호출하면 race condition 재발 가능

관련 링크

버전 노트

  • 본 메모는 dnd-kit v6.3.1 기준
  • v10+는 OptimisticSortingPlugin이 기본 활성화되어 메커니즘이 달라짐. 마이그레이션 시 재검증 필요