<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(document.querySelector('#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 패턴을 사용하여 이를 사용하는 구성 요소에서 상태 업데이트를 추상화하여 해당 구성 요소가 특정 기능에 더 집중하도록 만드는 방법을 설명. ↩
JSX 문자열
- https://github.com/TroyAlford/react-jsx-parser
- https://github.com/FormidableLabs/react-live
- https://github.com/seek-oss/playroom
프로젝트 파일 — iframe 안에서 컴파일·렌더, 의존성은 esm.sh
번들 URL
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>
)}
/>
)
}
row.name 이 되는 이유 — TS 는 Props<TRow> 와 넘긴 값을 나란히 놓고 TRow 를 줍는다.
후보가 여럿이면 자리에 따라 고른다.
TRow 를 두 번째로 쓰는 prop 은 NoInfer 로 줍기에서 뺀다 (TS 5.4+).
type Props<TRow> = {
rows: TRow[]
renderRow: (row: TRow, index: number) => React.ReactNode
selectedRow?: NoInfer<TRow>
}
extends Record<string, any> 는 원시값만 막는다.
추론 규칙 전체는 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)
}}
/>
)
}
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
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>
- 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 컴포넌트
isLoading 없이, onClick 에서 yield 만 한다. 핸들러를 async generator 로 받아 훅이 for await 로 돌며 yield 할 때마다 값을 바꾼다 — setLoading → setProgress → setLoading(false) 가 시간 순서대로 한 함수에 들어간다.
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)} />
언제 — 시간이 코드의 한 방향(↓)으로만 흐를 때: 멀티 단계 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 로만 바뀌면 된 것이다.
- 출처: @ericclemmons
바깥 클릭 감지를 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>
}
참고
dnd-kit drop 직후 튕김 — setQueryData 는 화면을 한 틱 늦게 바꾼다
dnd-kit Sortable + react-query에서 drop 직후 아이템이 원래 자리로 돌아갔다가 새 자리로 점프한다. 1~2프레임짜리 튕김이고 onDragEnd에서 setQueryData로 즉시 캐시를 갱신해도 그대로다.
③에서 확정되는 원인 — drop 애니메이션은 놓는 즉시 목적지를 재는데1, setQueryData의 리렌더는 notifyManager의 setTimeout(0)을 거쳐 한 틱 늦다.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
-
“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) ↩
Fiber 가 렌더링을 쪼갤 수 있는 이유 — 콜 스택 대신 세 포인터
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)
로딩 스피너 깜빡임의 두 끝 — 앞쪽은 CSS, 뒤쪽은 JS
스피너가 한 프레임 번쩍하고 사라지거나, 떴다가 하드컷으로 끊긴다. 깜빡임은 양 끝에서 따로 생기는데 CSS는 한쪽만 잡는다. 어느 끝인지는 응답 시각을 붙잡고 보인 시간을 재서 가른다 — DOM에 붙어 있던 시간이 아니다.
흐름
- headless Chrome + Node 전역
WebSocket으로 raw CDP. MCPemulate는 프리셋 스로틀링뿐이라 요청 하나를 못 붙잡는다. - 프로브 — 스피너가 사라질 때 보인 시간을 돌려준다.
- 경계값이면
disabled-by-default-devtools.screenshottrace로 확인한다. 스크린샷은 바뀐 프레임에만 찍히니 처음 물든 장 → 다음 흰 장이 보인 시간이다. - 응답 < 지연인데 보였으면 앞쪽, 응답 > 지연인데 최소 노출보다 짧으면 뒤쪽.
| 지연 | 응답 | 최소 노출 | 프로브 | 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
-
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)→ 흰색. ↩ -
CSS transition은 요소의 첫 스타일 적용이나
display: none→ 표시 전환에서는 기본적으로 발동하지 않는다.@starting-style이 “무엇에서 출발할지”를 정의해 그걸 가능하게 한다. (@starting-style — MDN) ↩
Suspense는 비동기를 동기처럼 보이게 만든다. 데이터가 아직 없으면 promise를 던지고, React가 그걸 받아 기다렸다가 다시 그린다. 그래서 상태를 조율하는 대신 입력을 미루고 → 질의하고 → 그리는 순서로 끝난다.
언제: 비동기 데이터를 그리는데 로딩·에러가 늘면서 화면이 튈 때. 또는 새로 만들면서 어느 쪽으로 갈지 고를 때.
절차
- 검색어·결과·로딩 중인지·에러인지를 각각 들고 있는지 본다.
- 분기를
if/else에서 트리로 옮긴다 — 로딩은<Suspense>, 에러는<ErrorBoundary>, 성공은 본문. - 본문에 loading·error를 인자로 넘기지 않는다. 안 넘겨도 가장 가까운 경계가 대신 잡기 때문이다 —
render는 결과 하나만 받는다.
확인: 있을 수 없는 조합을 적을 수 있는가. 적을 수 없으면 된 것이다.
함정: 스피너가 너무 빨리 뜨고 사라지는 건 이 축으로 안 고쳐진다 — 다른 축이다 → 573
곁가지 — 인자를 위로 들어올려 바깥이 처리하게 하는 이 모양은 대수적 효과(algebraic effects)의 handler와 같다.1
stale-while-revalidate(두 시계·isStale) 부분은 570으로.
Footnotes
-
try/catch와 비슷하다 — 중간 함수들은 몰라도 되고 가장 가까운 handler가 받는다. 다른 점은 처리하고 끝나지 않는다는 것이다. handler가 값을 돌려주면 효과를 일으킨 자리로 되돌아가 실행이 이어진다. (Algebraic Effects for the Rest of Us) ↩
useMemo 캐시 적중 진단 — 매 렌더 새로 만들어지는 deps
무거운 계산을 useMemo로 감쌌는데 렌더 시간이 줄지 않는다. 각 단계는 질문 하나에 답하고, 그 답이 다음 질문을 만든다. 단계마다 추론이 아니라 관찰로 넘어간다.
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
-
“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) ↩ ↩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
렌더 흐름에서 규칙이 걸리는 자리
① render — 유지돼야 하는 값은 useMemo 가 아니라 state
useMemo(getRandomColors, []) 로 만든 값이 HMR 뒤에 바뀐다.
const resolved = resolveTheme(mode, systemDark) // 순수 파생은 그냥 계산
const [colors] = useState(getRandomColors) // 유지가 곧 correctness 면 state
② useLayoutEffect — 서버 경고는 shim 으로
const useIsoLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect
③ useEffect — 이벤트는 노드가 사는 window 에
window.open·포털로 다른 창에 렌더해도 window.addEventListener 는 원래 창을 듣는다.
const win = ref.current?.ownerDocument.defaultView ?? window
멀티윈도우로 쓰일 수 있는 라이브러리 컴포넌트에만 필요하다.
④ ref 콜백 — cleanup 을 반환한다 (React 19)
<div
ref={(node) => {
const ctrl = new AbortController()
node.addEventListener('scroll', onScroll, { signal: ctrl.signal })
return () => ctrl.abort()
}}
/>