<div
dangerouslySetInnerHTML={{
__html: `
<img src="http://unsplash.it/100/100?random" onload="console.log('you got hacked');" />
`,
}}
/>
가끔 아주 가끔 이상한 일을 해야할때가 있는데 그럴때는 이렇게 하면 된다.
// cannot be used as a JSX component
@types/react, @types/react-dom에 대한 참조가 잘못되면서 발생하는 이슈
- React + Webpack: ChunkLoadError: Loading chunk X failed. | by Raphaël Léger | Medium
- How to fix ChunkLoadError in your ReactJS application - Codemzy’s Blog
lazy로 import했을 경우 ChunkLoadError가 발생하는데 이럴 경우 어떻게 대응할 수 있는지 정리해둔 글들.
모바일 웹뷰에서 특정 영역을 zoom할수 있게 해달라는 요청이 있어서 적용한 내역. 정확히 기억은 안나는데 react-prismazoom를 선택했다.
react-prismazoom은 CSS 변환을 사용하여 React에서 확대 및 이동 기능을 제공하는 팬 및 줌 컴포넌트입니다. 이 라이브러리는 prop-types, react, react-dom 모듈에만 의존하며, 데스크톱 및 모바일에서 모두 작동합니다.
주요 기능 및 특징
- 확대 기능 : 마우스 휠이나 두 손가락으로 확대할 수 있습니다. 더블 클릭 또는 더블 탭을 사용하여 확대할 수도 있으며, 선택한 영역을 확대하여 중앙에 배치할 수 있습니다.
- 이동 기능 : 마우스 포인터나 줌 인 상태에서 손가락을 사용하여 이동할 수 있습니다. 확대된 상태에서는 사용 가능한 공간에 따라 직관적으로 이동합니다. 요소를 이동할 수 있는 방향을 나타내기 위해 커서 스타일을 조정합니다.
그 외 비슷한
const root = createRoot('#confirm-root')
root.render(<Confirm />)
confirm ui를 만들다보면 window.confirm을 호출하는 방식으로 사용하는게 가장 좋은 방법인데 (안그러면 state로 관리해야하고 결국 이건 무의미한 코드의 반복이다.)
이걸 react로 구현하려면 결국 render를 사용해야함. react-confirm, react-confirm-alert 둘다 소스를 보면 비슷한 방식으로 접근한다.
if (!data) {
throw fetch()
}
- 컴포넌트가 렌더링될 때, 비동기 작업(예: 데이터 패칭)이 시작됩니다. 이 작업은 일반적으로 promise를 반환합니다.
- 비동기 작업이 완료되지 않은 경우, 컴포넌트는 promise를 던집니다. 이는 JavaScript에서 예외를 던지는 것과 유사합니다. Suspense는 promise가 던져질 때 이를 캐치하고 fallback UI를 표시하는 역할을 합니다.
- React는 컴포넌트가 promise를 던졌을 때 이를 감지하고, Suspense 컴포넌트에서 이를 “캐치”합니다. Suspense는 이 promise가 해결될 때까지 대체 UI (fallback)를 렌더링합니다. Concurrent Mode에서는 React가 이 promise를 추적하고, 비동기 작업이 완료될 때까지 렌더링을 중단합니다.
- Promise가 해결되면(즉, 비동기 작업이 완료되면) React는 컴포넌트를 다시 렌더링합니다. Suspense는 현재 데이터 패칭 라이브러리(예: React Query, SWR)와 함께 사용되어 비동기 작업의 상태를 쉽게 관리할 수 있도록 도와줍니다.
- https://github.com/facebook/react/blob/main/packages/react/src/ReactLazy.js#L119C19-L119C26
- https://github.com/TanStack/query/blob/main/packages/react-query/src/suspense.ts#L62
- https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/throw
- https://jser.pro/ddir/rie?reactVersion=18.3.1&codeKey=ud62nsxll29yy0dzba8
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
이 패턴은 컴포넌트를 작성할 때 불필요한 스타일링이나 구조를 미리 정의하지 않고, 각 컴포넌트가 자신의 역할에 충실할 수 있도록 도와줍니다. 이를 통해 코드의 유연성을 유지하고, 필요에 따라 컴포넌트를 확장하거나 수정할 수 있는 여지를 남겨두게 됩니다.
💡이 패턴이 “상태 머신”처럼 들린다면, 그리 놀랄 일도 아닙니다. 결국, 선택의 문제는 상태 머신을 구축할지 말지가 아니라, 그것을 암시적으로 구축할지 명시적으로 구축할지에 달려 있습니다.
stateDiagram-v2
[*] --> Mounting
Mounting --> AwaitingEmailInput
Mounting --> AwaitingCodeInput
AwaitingEmailInput --> SubmittingEmail
SubmittingEmail --> AwaitingCodeInput
SubmittingEmail --> AwaitingEmailInput
AwaitingCodeInput --> SubmittingCode
SubmittingCode --> Success
SubmittingCode --> AwaitingCodeInput
Success --> [*]
폼 제출 시 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()
})
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)
})
조건부 렌더링을 한다고 했을때 예전에는 주로 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}</>
useSyncExternalStore 활용 가능한 부분들
💡
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,
}
}
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
-
리듀서를 사용하여 예측 가능하고 테스트 가능한 방식으로 상태 업데이트 및 작업을 캡슐화하는 방법과 State Reducer 패턴을 사용하여 이를 사용하는 구성 요소에서 상태 업데이트를 추상화하여 해당 구성 요소가 특정 기능에 더 집중하도록 만드는 방법을 설명. ↩
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를 렌더링함. - 이 구조는 테스트를 쉽게 하고, 컴포넌트의 역할을 명확하게 분리함.
- Using Composition in React to Avoid “Prop Drilling”1
- Merrick Christensen - Headless User Interface Components2
- Headless Component: a pattern for composing React UIs
- 유연성: 정책을 메커니즘에서 분리하고 인터페이스를 엔진에서 분리하면 소프트웨어 구성 요소를 설계하고 구현할 때 더 큰 유연성을 확보할 수 있습니다. 따라서 시스템의 나머지 부분에 영향을 주지 않고 구성 요소를 수정하거나 교체하기가 더 쉬워집니다.
- 재사용 가능성: 컴포넌트를 별개의 모듈로 분리하면 다양한 컨텍스트나 애플리케이션에서 사용할 수 있는 재사용 가능한 빌딩 블록을 만들 수 있습니다. 이를 통해 코드 재사용을 촉진하여 개발 시간을 단축하고 코드 품질을 개선할 수 있습니다.
- 테스트 가능성: 메커니즘을 정책에서 분리하고 인터페이스를 엔진에서 분리하면 개별 구성 요소를 개별적으로 테스트하기가 더 쉬워져 전반적인 테스트 범위가 개선되고 버그나 회귀의 위험이 줄어듭니다.
- 유지 관리 가능성: 컴포넌트를 별개의 모듈로 분리하면 시간이 지남에 따라 코드를 더 쉽게 유지 관리하고 디버그할 수 있습니다. 또한 시스템의 나머지 부분에 영향을 주지 않고 개별 컴포넌트의 문제나 버그를 더 쉽게 식별하고 수정할 수 있습니다.
- 확장성: 컴포넌트를 분리하면 특정 요구 사항에 따라 여러 컴포넌트를 독립적으로 확장할 수 있어 소프트웨어 애플리케이션을 더 쉽게 확장할 수 있습니다.
- 상호 운용성: 구성 요소를 분리하면 서로 다른 시스템 간에 통신하는 데 사용할 수 있는 잘 정의되고 표준화된 인터페이스를 생성하여 서로 다른 시스템 또는 구성 요소 간의 상호 운용성을 향상시킬 수 있습니다.
- 민첩성: 컴포넌트를 분리하면 나머지 시스템에 영향을 주지 않고 개별 컴포넌트를 더 빠르게 반복하고 변경할 수 있어 민첩성을 향상시킬 수 있습니다.
Footnotes
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
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)
}}
/>
)
}
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()
})
}}>
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로 명령형 트리 순회 제공하는 이중성.
- react-children-utilities - deepMap, deepFind, deepFilter
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 고려.
대용량 리스트에서 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 고려.
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 발생.
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()는 실제 크기 반환.
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>
)
}
컴포넌트가 렌더링하는 요소를 외부에서 제어하는 세 가지 패턴.
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
- Base UI useRender - render prop 커스텀 훅 구현
- React Aria render prop PR - React Aria Components에 render prop 도입
- Slot/asChild Pattern - Radix Slot vs Base UI render 비교
- React as prop (christianvm) - ElementType, ComponentPropsWithoutRef 타입 활용
- slot-jsx-pragma - cloneElement 없이 asChild 구현하는 JSX pragma
- React as prop (robinwieruch) - 디자인 시스템에서 시맨틱 HTML 유지하며 스타일 분리
- asChild in React, Svelte, Vue, Solid - 프레임워크별 render delegation 비교
- React asChild (jacobparis) - asChild 패턴 구현 튜토리얼
- Polymorphism done well - 타입 안전한 polymorphic 컴포넌트
여러 단계의 비동기 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개 콜백 대신 한 함수로
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 + react-query에서 drop 직후 튕김(flicker) = 렌더를 react-query 캐시로 하면, dnd-kit의 drop 측정이 의존하는 React commit phase(setState)를 안 타기 때문. → 렌더는 local state, 캐시는 부수효과로 격리.
현상
drop 직후 1~2프레임 동안 아이템이 원래 위치로 돌아갔다가 새 위치로 점프. onDragEnd에서 setQueryData로 즉시 캐시를 갱신해도 발생하고, DragOverlay에서 더 두드러진다. (내 코드 문제 아님 — dnd-kit 알려진 동작)
원인
dnd-kit의 drop 애니메이션이 destination을 ASAP 측정하는데, react-query 캐시 직접 업데이트는 dnd-kit이 의존하는 React commit 사이클을 안 탄다 → reorder 적용 전 프레임을 catch해 원위치로 애니메이션 후 점프. (#833·#921·#1522 — 답변자들도 결국 별도 local state로 우회)
해결
렌더 source-of-truth를 local state로 두고, 캐시 갱신은 서버 동기화 부수효과로 분리한다.
function SortableList() {
const { data: serverItems = [] } = useQuery({ queryKey: ['items'], queryFn: fetchItems })
const [items, setItems] = useState<Item[]>(serverItems) // 렌더용 source of truth
// 드래그 직후 refetch가 옛 순서로 덮어쓰는 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 = ({ 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) // 1) local state 먼저 — dnd-kit이 이걸 보고 측정
reorderMutation.mutate(next) // 2) 서버 동기화는 별개
}
// <DndContext onDragEnd={handleDragEnd}> <SortableContext items={items.map(i=>i.id)}> ...
}
주의:
- invalidate race —
isReorderingRef없으면 mutation 직후 refetch가 옛 순서를 들고 와 useEffect가 local을 덮음(또 다른 튕김). 가장 깔끔한 변형:mutationFn이 새 순서를 반환 →onSuccess에서setItems(data), invalidate 제거. - stable id —
SortableContext·자식key·useSortable({ id })가 모두 같은 id. 인덱스 key 금지. - DragOverlay 잔여 flicker —
dropAnimation={null}로 제거(겸 진단: 사라지면 #833 확정).
교훈
라이브러리가 React commit lifecycle에 암묵적으로 의존하면, 그 옆 채널(캐시 직접 변경)로 상태를 바꿀 때 어긋난다 — 렌더의 source-of-truth를 commit을 타는 한 곳으로 모은다.
참고
- dnd-kit#833 · #921 · Discussions#1522
- v6.3.1 기준. v10+는
OptimisticSortingPlugin기본 활성화로 메커니즘이 다름 → 재검증 필요.
재귀(콜 스택) 대신 child / sibling / return 세 포인터로 트리를 순회한다 → 스택 없이 DFS pre-order, 언제든 중단·재개 가능. Fiber가 렌더링을 쪼갤 수 있는 이유.
재귀로 짜면 진행 상태가 JS 콜 스택에 쌓이는데, requestIdleCallback으로 도중에 yield하면 그 스택을 버리게 된다. 재개하려면 스택을 다시 쌓아야 해서 “어디까지 했는지”를 외부에서 들고 있어야 한다.
그 상태를 workInProgress 포인터 하나로 환원한다. 노드마다 걸린 세 링크를 직접 따라가므로 호출 스택이 필요 없다.
1. child 있으면? → 내려간다
2. root에 도달했으면? → 종료
3. sibling 없으면? → return(부모)으로 올라가며 반복
4. sibling 있으면? → 옆으로 간다
내려가는 길 = beginWork, 올라오는 길 = completeWork. 한 노드를 두 번(하강·상승) 지나는 흐름이 그대로 두 단계로 갈린다.
싱글톤이 안티패턴이라는 통념은 싱글톤 자체가 아니라 React에 붙이는 방식이 후졌던 데서 나왔다. EventTarget 확장 + useSyncExternalStore면 프레임워크 무관 코어를 stale 없이 연결한다. 코어(표준 JS 클래스, React를 모름) / 어댑터(useSyncExternalStore 한 겹)로 분리 — Vue·Svelte는 어댑터만 갈아끼운다.
연결 방식의 진화:
- polling / 수동 refresh — stale, 데이터 변경과 무관한 리렌더
useEffect+addEventListener— 낫지만 로컬useState를 손으로 동기화 (React state스럽지 않음)useSyncExternalStore— 구독·동기화를 React가 직접 관리, stale closure 제거
const subscribe = (cb: () => void) => {
const ac = new AbortController()
toastManager.addEventListener('changed', cb, { signal: ac.signal })
return () => ac.abort()
}
const getSnapshot = () => toastManager.toasts
const toasts = useSyncExternalStore(subscribe, getSnapshot)
코어의 핵심은 setter 하나 — 상태 변경과 이벤트 발행을 원자적으로 묶는다:
private set toasts(value: Toast[]) {
this._toasts = [...value] // 새 배열 → 참조 동등성으로 변화 감지
this.dispatchEvent('changed') // 변경 시 발행 누락 불가능
}
함정: getSnapshot은 참조가 안정적이어야 한다. 매번 새 객체를 리턴하면 무한 루프 — getter는 내부 배열을 그대로 반환하고, 새 배열은 setter에서만 만든다.
같은 계열 — 프레임워크 밖에 source of truth를 두고 UI는 구독만 하는 사고방식: build-time swap(562), IndexedDB-first(561).
useMemo — 적중은 Object.is(deep-equal 아님)
useMemo 적중 조건은 Object.is(참조/원시 동등)이지 deep-equal이 아니다. 그래서 인라인 items={data.map(...)}은 내용이 같아도 매 렌더 miss(참조가 매번 새로움). 정확성은 별개 — 순수 가정(매 렌더 f() = φ(deps))이 서면 적중이든 miss든 반환값은 항상 fresh φ(deps). stale closure(deps에 없는 변수 포획)는 그 가정 자체를 깨서 stale 값을 낸다.
useDeferredValue — items와 무관 (핵심)
deferred 궤적은 자기 입력(query)에만 의존하고 items 참조 안정성과 무관(증명이 rfl인 이유 — items가 계산에 안 들어감). 그래서 items가 매 렌더 새로 만들어져도 죽는 건 useMemo 캐시 한 층뿐, deferred는 안 흔들린다.
안전성: deferred는 항상 지금까지 본 입력 중 하나(미래·날조값 아님). 수렴: 입력이 안정화되고 저우선순위 렌더가 무한 선점 안 되면 따라잡음 — 그 격차 구간이 isStale(opacity dim 창). 연속 타이핑 시 영영 못 따라잡을 수 있는데 버그가 아니라 의도(그 내내 dim 유지).
형식 닻: renderDeferred_const_in_items(items에 상수), converge(안정화 후 따라잡음).
로딩 flicker는 두 엣지인데 CSS는 한쪽만 잡는다:
- leading — 응답이 너무 빨라 스피너가 뜨기 전/직후 사라짐. 언제 보이기 시작하나(delay D)의 문제. → CSS가 잡음:
animation-delay/transition-delay가 곧 D. τ ≤ D면 한 프레임도 안 보인다(빠른 응답이 스피너 통째로 건너뜀). 단 억제력은 오직 D에서 —@starting-style단독(D=0)으론 못 막는다. - trailing — 스피너가 떴는데 너무 짧게 보이고 하드컷. 언제 사라지나(τ)의 문제. 떴다는 사실은 못 되돌리니
(D,P)로는 못 잡고 τ 자체를 미뤄야 한다 = CSS 밖, JS 타이밍. →useMinimumLoading: unmount를max(resolve, minLoad)로 밀어 한 번 뜬 스피너 가시 길이 ≥ minLoad 보장.
형식 닻: leading_edge_suppressed(τ≤D면 비가시), visible_ge_minLoad(τ-push 보장).
곁가지 — @starting-style은 flicker와 무관한 다른 축: transition의 from↔to를 distinct하게 만들어 마운트 보간이 죽지 않게 하는 것(진입 fade-in). 그래서 진입 애니메이션과 flicker 해법(useMinimumLoading)은 별개 결정.
useMemo는 성능 힌트지 의미적 보장이 아니다 — 정확성을 캐시에 의존하면 안 된다. React는 특별한 이유가 있으면 캐시를 버린다: 개발 중 HMR, 초기 마운트 중 suspend, 미래 기능(오프스크린/가상 리스트). 그래서 useMemo 결과로 “한 번 정해지면 유지돼야 하는 값”을 만들면, 캐시가 폐기되는 순간 값이 바뀐다.
// ❌ 캐시 폐기 시 색이 재생성 → flicker
const colors = useMemo(() => getRandomColors(baseTheme), [baseTheme])
// ✅ 영속성이 정확성 조건이면 state로
const [colors, setColors] = useState(() => generateAccentColors(baseTheme))
const [prevTheme, setPrevTheme] = useState(baseTheme)
if (baseTheme !== prevTheme) {
// 렌더 중 prev 비교 = derived state
setPrevTheme(baseTheme)
setColors(generateAccentColors(baseTheme))
}
판별 기준: “이 값이 재계산되면 틀린 동작인가, 아니면 그냥 느린 동작인가.” 틀림 → state/ref. 느림 → useMemo.
참고
declarative Combobox = render ∘ query ∘ defer. Suspense가 query: Q→Promise⟨D⟩를 throw로 바꿔 동기 Q→D로 위장하니, 전체가 명령형 조율이 아니라 함수 합성이 된다.
상태공간 곱→합. manual은 Q × Results × loading × error 곱집합이라 loading∧error 같은 모순 셀이 타입상 실재 — flicker는 렌더가 그 셀을 지날 때의 증상. 선언적 합 타입(loading | error | success)은 모순을 구성 불가능하게 만들고, case 분기를 if/else가 아니라 트리(<Suspense> / <ErrorBoundary> / 본문)가 담당한다. (형식 닻: ui_exclusive — 모든 값은 정확히 한 case.)
Effect lifting = 자유 변수가 트리의 형태로. loading/error가 render의 인자(자유 변수)에서 사라지고, 가장 가까운 경계(<Suspense>/<ErrorBoundary>)가 잡는 ambient effect로 들린다 — render는 Results 하나만 받는다. 대수적 효과(algebraic effects)의 handler 구조와 같은 모양.
stale-while-revalidate(two clocks·isStale) 부분은 [[570]]로.