<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를 선택했다.
그 외 비슷한
const root = createRoot('#confirm-root')
root.render(<Confirm />)
confirm ui를 만들다보면 window.confirm을 호출하는 방식으로 사용하는게 가장 좋은 방법인데 (안그러면 state로 관리해야하고 결국 이건 무의미한 코드의 반복이다.)
이걸 react로 구현하려면 결국 render를 사용해야함. react-confirm, react-confirm-alert 둘다 소스를 보면 비슷한 방식으로 접근한다.
if (!data) {
throw fetch()
}
Suspense는 던져진 promise를 캐치해서 resolve될 때까지 fallback을 렌더하고, resolve되면 다시 렌더한다. 던지는 건 예외와 같은 메커니즘이다.
- https://github.com/facebook/react/blob/eb8feb71096eec5c885b2a4c7d8d030d3622f265/packages/react/src/ReactLazy.js#L222
- https://github.com/TanStack/query/blob/b8e35598ef24855a2792414faea5721d833fa61e/packages/react-query/src/suspense.ts#L61
- 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를 렌더링함. - 이 구조는 테스트를 쉽게 하고, 컴포넌트의 역할을 명확하게 분리함.
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
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 직후 아이템이 원래 자리로 돌아갔다가 새 자리로 점프한다. 1~2프레임짜리 튕김이고, DragOverlay를 쓰면 더 두드러진다. onDragEnd에서 setQueryData로 즉시 캐시를 갱신해도 그대로다.
왜 그런가
dnd-kit의 drop 애니메이션은 놓는 즉시 목적지를 잰다. 그 순간 화면이 아직 옛 순서면 옛 자리를 목적지로 재고, 거기로 애니메이션한 다음 점프한다.1
그러니 문제는 새 순서가 언제 화면에 닿느냐다. 두 경로의 시점이 다르다.
onDragEnd안에서 부른setState— 그 이벤트 처리 안에서 반영된다.queryClient.setQueryData— 캐시는 즉시 바뀌지만, 구독자에게 알리는 건 react-query의notifyManager를 거친다. 기본 스케줄러가setTimeout(fn, 0)이라 리렌더가 다음 매크로태스크로 밀린다.2
캐시는 바로 바뀌는데 화면은 한 틱 뒤에 바뀐다. 그 사이에 dnd-kit이 잰다.
그래서 이렇게 한다
렌더의 source-of-truth를 local state에 두고, 캐시 갱신은 서버 동기화 부수효과로 분리한다.
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)}> ...
}
주의:
- invalidate race —
isReorderingRef없으면 mutation 직후 refetch가 옛 순서를 들고 와useEffect가 local을 덮는다(또 다른 튕김). 서버가 새 순서를 반환한다면onSuccess에서 바로 받아 이 가드를 통째로 없앨 수 있다. - stable id —
SortableContext·자식key·useSortable({ id })가 전부 같은 id여야 한다. 인덱스를 key로 쓰지 않는다. DragOverlay잔여 튕김 —dropAnimation={null}로 없앨 수 있다. 진단도 겸한다: 이걸로 사라지면 위 원인이 맞다.
교훈
라이브러리가 렌더 직후 DOM을 재는 동작을 하면, 상태 갱신과 화면 반영 사이에 지연을 넣는 채널로 상태를 바꿀 때 어긋난다. 상태가 언제 바뀌는지가 아니라 화면이 언제 바뀌는지가 기준이다.
v6.3.1 기준. v10+는
OptimisticSortingPlugin이 기본 활성화라 기제가 다르다 → 재검증 필요.
Footnotes
-
“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) ↩
-
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) ↩
재귀(콜 스택) 대신 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).
검색어를 치면 입력창은 바로 바뀌어야 하는데, 아래 무거운 목록까지 매번 다시 그리면 버벅인다. useDeferredValue(value)는 같은 값을 한 박자 늦게 따라오는 짝으로 하나 더 만들어준다. 입력창은 빠른 쪽을, 무거운 목록은 늦은 쪽을 쓴다.
그럼 늦은 쪽이 보여주는 값을 믿어도 되나.
어떻게 도는지부터. 글자를 하나 치면 React가 화면을 두 번 그린다. 첫 번째는 빠른 쪽만 새 값으로(query는 이미 "ab"인데 deferredQuery는 아직 "a"), 두 번째는 늦은 쪽까지 새 값으로. 두 번째 그리기는 첫 번째가 끝나자마자 시작한다 — 기다리는 시간 같은 건 없다. 그리는 도중에 글자를 더 치면 그때까지 그린 걸 버리고 처음부터 다시 그린다. 다 그려서 화면에 올리기 전까지는 아무것도 안 보이고 useEffect도 안 돈다.1
- 없는 값을 만들지 않는다 — 늦은 쪽이 보여주는 건 언제나 _내가 실제로 쳤던 것 중 하나_다. 지어내지 않는다.
- 결국 따라잡는다 — 손을 멈추면 따라잡는다. 계속 치는 동안은 다시 그리기가 반복해서 취소돼 영영 못 따라잡을 수도 있는데, 고장이 아니라 그러라고 만든 것이다. 그 벌어진 사이가
isStale이고, 보통 화면을 흐리게 해서 “아직 옛날 거예요”를 알린다.
둘 다 위 동작에서 그대로 나온다 — 돌려줄 값을 새로 만드는 일이 없으니 지어낼 수가 없고, 다시 그리기가 늘 최신 값에서 출발하니 손을 멈추면 그 그리기가 끝까지 간다. 믿어도 된다. 다만 언제 따라잡을지는 모른다.
목록을 인라인으로 만들어도 늦은 쪽은 안 흔들린다. items={data.map(...)}처럼 써서 참조가 매번 새로 생겨도 상관없다 — 그 목록이 늦은 쪽 계산에 아예 안 들어가기 때문이다. 같은 상황에서 망가지는 건 useMemo 캐시 쪽이다 → 578
Footnotes
-
두 단계 — “First, React re-renders with the new
query("ab") but with the olddeferredQuery(still"a") … In the background, React tries to re-render with bothqueryanddeferredQueryupdated.” / 타이밍 — “There is no fixed delay caused byuseDeferredValueitself. As soon as React finishes the original re-render, React will immediately start working on the background re-render.” / 중단 — “The background re-render is interruptible: if there’s another update to the value, React will restart the background re-render from scratch.” / Effect — “does not fire Effects until it’s committed to the screen.” (useDeferredValue — React) ↩
로딩 스피너의 깜빡임은 양 끝에서 따로 생기는데 CSS는 한쪽만 잡는다.
- 앞쪽(leading) — 응답이 너무 빨라 스피너가 뜨기 전이나 직후 사라진다. 언제 보이기 시작하나의 문제다. → CSS가 잡는다:
animation-delay/transition-delay가 곧 그 지연이고, 응답이 지연보다 빨리 오면 한 프레임도 안 보인다(스피너를 통째로 건너뛴다). 단 억제력은 지연에서만 나온다 —@starting-style단독(지연 0)으론 못 막는다. - 뒤쪽(trailing) — 스피너가 떴는데 너무 짧게 보이고 하드컷된다. 언제 사라지나의 문제다. 이미 떴다는 사실은 못 되돌리니 CSS가 가진 두 값(지연·지속 시간)으로는 못 잡고 사라지는 시각 자체를 미뤄야 한다 — CSS 밖, JS 타이밍이다. →
useMinimumLoading: unmount를max(응답 시각, 최소 노출 시간)으로 밀어, 한 번 뜬 스피너가 최소 노출 시간만큼은 보이게 만든다.
둘이 갈리는 이유는 하나다 — CSS는 시작만 통제한다. 스피너가 보이는 구간은 [뜬 시각, unmount 시각]인데, 지연은 앞쪽을 미루고 지속 시간은 애니메이션 길이일 뿐 요소가 DOM에 남는 시간이 아니다. 뒤쪽 끝은 응답이 언제 오느냐가 정하고 그건 CSS가 모르는 값이다. 그래서 “최소 몇 ms는 보인다”를 CSS만으로는 보장할 수 없다.
곁가지 — @starting-style은 같은 뿌리의 반대편이다. transition은 두 상태 사이를 보간하는데, 진입에는 옮겨갈 이전 상태가 없고1 퇴장에는 요소 자체가 이미 사라진다. @starting-style은 앞쪽의 출발값을 대신 정해주고(진입 fade-in), useMinimumLoading은 뒤쪽의 unmount를 미룬다. 뿌리는 같아도 손대는 지점이 달라 별개 결정이다.
Footnotes
-
CSS transition은 요소의 첫 스타일 적용이나
display: none→ 표시 전환에서는 기본적으로 발동하지 않는다.@starting-style이 “무엇에서 출발할지”를 정의해 그걸 가능하게 한다. (@starting-style — MDN) ↩
테마에서 뽑은 강조색처럼 한 번 정해지면 유지돼야 하는 값은 useMemo에 두면 안 된다. useMemo가 보장하는 건 “값이 맞다”이지 “같은 값이 유지된다”가 아니다. React는 특별한 이유가 있으면 캐시를 버린다 — 개발 중 파일을 고칠 때, 초기 마운트 중 컴포넌트가 suspend할 때, 앞으로 나올 기능(가상 리스트에서 화면 밖으로 스크롤된 항목 같은 것). 계산이 순수하면 버려져도 같은 답이 나오니 상관없지만, 랜덤처럼 매번 다른 답을 내는 계산이면 버려지는 순간 색이 바뀐다.
그래서 useState에 둔다. 여기에 놓치기 쉬운 게 둘 있다.
const [colors, setColors] = useState(() => generateAccentColors(baseTheme))
const [prevTheme, setPrevTheme] = useState(baseTheme)
if (baseTheme !== prevTheme) {
setPrevTheme(baseTheme)
setColors(generateAccentColors(baseTheme))
}
1. useState(() => ...) — 괄호를 붙이지 않는다. useState(generateAccentColors(baseTheme))라고 쓰면 함수가 매 렌더마다 돌고 결과는 첫 렌더 말고는 전부 버려진다. 함수 자체를 넘기면 React가 처음 한 번만 부른다.1
2. 테마가 바뀌면 렌더 중에 갈아끼운다. if (이전 값 !== 지금 값) 안에서 이전 값을 갱신하고 새 값을 만든다. 이 모양이어야 하는 이유가 있다 — React는 렌더 중 set을 보면 return 직후 화면에 그리기 전에 그 컴포넌트만 다시 렌더한다(자식은 두 번 안 그린다). 그래서 깜빡임이 없다. 다만 조건이나 setPrevTheme 중 하나라도 빠지면 무한 루프로 터진다.1
판별 기준: “이 값이 재계산되면 틀린 동작인가, 아니면 그냥 느린 동작인가.” 틀림 → state/ref. 느림 → useMemo.
재활용 자체가 안 되는 쪽(참조가 매 렌더 새로워지는 경우)은 속도 문제일 뿐이다 → 578
참고
Footnotes
-
게으른 초기화 — “Although the result of
createInitialTodos()is only used for the initial render, you’re still calling this function on every render … If you pass a function touseState, React will only call it during initialization.” / 렌더 중set— “React will re-render that component immediately after your component exits with areturnstatement, and before rendering the children.” / 필수 조건 — “it must be inside a condition likeprevCount !== count, and there must be a call likesetPrevCount(count)inside of the condition. Otherwise, your component would re-render in a loop until it crashes.” (useState — React) ↩ ↩2
Suspense는 비동기를 동기처럼 보이게 만든다. 데이터가 아직 없으면 promise를 던지고, React가 그걸 받아 기다렸다가 다시 그린다. 그래서 상태를 조율하는 대신 입력을 미루고 → 질의하고 → 그리는 순서로 끝난다.
직접 만들면 넷을 따로 들고 있어야 한다 — 검색어, 결과, 로딩 중인지, 에러인지. 넷이 각각 켜지고 꺼지니 실제로는 있을 수 없는 조합까지 코드가 표현할 수 있다. “로딩 중이면서 동시에 에러” 같은 것. 그 조합이 한 프레임이라도 그려지면 화면이 튄다. (스피너가 너무 빨리 뜨고 사라지는 쪽은 다른 축 — 573)
선언적으로 쓰면 상태가 셋 중 하나가 된다 — 로딩이거나, 에러거나, 성공이거나. 있을 수 없는 조합은 아예 적을 수가 없다.
분기도 if/else가 아니라 트리가 맡는다. 로딩은 <Suspense>, 에러는 <ErrorBoundary>, 성공은 본문.
그래서 render가 가벼워진다. loading·error를 인자로 넘길 필요가 없다 — 안 넘겨도 가장 가까운 경계가 대신 잡기 때문이다. render는 결과 하나만 받는다.
곁가지 — 인자를 위로 들어올려 바깥이 처리하게 하는 이 모양은 대수적 효과(algebraic effects)의 handler와 같다.1
stale-while-revalidate(두 시계·isStale) 부분은 570으로.
Footnotes
-
try/catch와 비슷하다 — 중간 함수들은 몰라도 되고 가장 가까운 handler가 받는다. 다른 점은 처리하고 끝나지 않는다는 것이다. handler가 값을 돌려주면 효과를 일으킨 자리로 되돌아가 실행이 이어진다. (Algebraic Effects for the Rest of Us) ↩
목록을 걸러내거나 정렬하는 계산이 무거우면 매 렌더마다 다시 하기 아깝다. useMemo는 지난번 결과를 기억해뒀다가, 재료가 그대로면 그걸 다시 쓰는 것이다.
문제는 “재료가 그대로”를 어떻게 판단하느냐다. React는 재료 안을 들여다보지 않는다 — Object.is로 같은 물건인지만 본다.1 내용이 똑같은 배열이라도 매번 새로 만들면 다른 물건으로 친다.
그래서 items={data.map(...)}처럼 인라인으로 넘기면 한 번도 재활용되지 않는다. 렌더마다 새 배열이 생기니까.
그래도 화면이 틀리지는 않는다. 재활용을 못 하면 다시 계산할 뿐이고, 같은 재료로 늘 같은 답을 내는 계산이라면 답은 똑같다. 잃는 건 속도뿐이다. React가 이 전제를 얼마나 중요하게 보는지는 개발 모드에서 드러난다 — 계산 함수를 일부러 두 번 불러서 답이 달라지는지 확인한다.
그 “늘 같은 답” 전제가 깨지는 경우가 둘 있다.
- 재료 목록(
deps)에 안 적어둔 값을 계산 안에서 쓰면(stale closure) — 그 값이 바뀌어도 React는 “재료 그대로”로 보고 옛날 답을 내놓는다. 느린 게 아니라 틀린 것이다. 게다가 무관한 재료가 바뀌는 순간 우연히 맞아졌다가 다시 틀린다 — 그래서 원인을 찾기 어렵다. - 계산이 매번 다른 답을 내면(랜덤 같은 것) — React는 특별한 이유가 있으면 기억해둔 걸 버리는데, 그 순간 답이 바뀐다 → 574
Footnotes
-
“React will compare each dependency with its previous value using the
Object.iscomparison.” / “React will not throw away the cached value unless there is a specific reason to do that … This should be fine if you rely onuseMemosolely as a performance optimization.” / “In Strict Mode, React will call your calculation function twice in order to help you find accidental impurities.” (useMemo — React) ↩