API Reference
export type OverlayResolved<ResolvedValue> = {
status: 'resolved'
value: ResolvedValue
}
export type OverlayDismissed<DismissedReason> = {
status: 'dismissed'
reason?: 'unmount' | DismissedReason
}
export type OverlayOutcome<ResolvedValue, DismissedReason> =
| OverlayDismissed<DismissedReason>
| OverlayResolved<ResolvedValue>
function useAsyncFlow<ResolvedValue = unknown, DismissedReason = unknown>(options?: {
/**
* The element to restore focus to after the `open()` is resolved or dismissed.
*
* @default 'previous'
*/
restoreFocus?: 'previous' | (() => HTMLElement | null) | HTMLElement | { selector: string }
/**
* Whether to restore focus to the element that triggered the `open()` when the `open()` is resolved by `resolve()`.
*
* @default true
*/
restoreFocusOnResolved?: boolean
/**
* Whether to restore focus to the element that triggered the `open()` when the `open()` is dismissed by `dismiss()`.
*
* @default true
*/
restoreFocusOnDismissed?: boolean
/**
* @default true
*/
dismissOnUnmount?: boolean
}): {
isOpen: boolean
open: (
event?: { currentTarget: EventTarget },
options?: {
restoreFocus?: 'previous' | (() => HTMLElement | null) | HTMLElement | { selector: string }
restoreFocusOnResolved?: boolean
restoreFocusOnDismissed?: boolean
}
) => Promise<OverlayOutcome<ResolvedValue, DismissedReason>>
resolve: (value: ResolvedValue) => void
dismiss: (reason?: DismissedReason) => void
}
Returns
isOpen
: 모달의 열림 상태open
: 모달을 열고 Promise를 반환resolve
: 모달을 확정 (값과 함께)dismiss
: 모달을 취소 (이유와 함께)
resolve
를 호출하면, open()
의 Promise가 { status: 'resolved', value: ResolvedValue }
로 resolve됩니다.
dismiss
를 호출하면, open()
의 Promise가 { status: 'dismissed', reason: DismissedReason }
로 resolve됩니다.
이 두가지를 조합하여 모달이 닫히는 다양한 조건을 체계적으로 처리할 수 있습니다.
open() 함수의 options 파라미터
open()
함수의 두 번째 파라미터로 RestoreFocusOptions
를 전달할 수 있습니다. 이 옵션들은 해당 호출에만 적용되며, 훅 레벨에서 설정한 기본 옵션보다 우선순위가 높습니다.
const overlay = useAsyncFlow({
restoreFocusOnResolved: true, // 훅 레벨 기본값
restoreFocusOnDismissed: true,
})
// 이 호출에서만 dismiss 시 포커스 복구 비활성화
const result = await overlay.open(event, {
restoreFocusOnDismissed: false, // 이 옵션이 훅 레벨 설정을 덮어씀
})
사용 가능한 옵션:
restoreFocus
: 포커스를 복구할 대상 요소 지정restoreFocusOnResolved
: resolve 시 포커스 복구 여부restoreFocusOnDismissed
: dismiss 시 포커스 복구 여부
이를 통해 각각의 모달 호출마다 서로 다른 포커스 복구 전략을 적용할 수 있습니다.
Focus behavior with open(event)
open()
에event
객체를 전달하면, 모달이resolve
또는dismiss
되는 시점에event.currentTarget
으로 포커스를 옮깁니다. 이는 모달이 닫힐 때 모달을 연 버튼(트리거)으로 포커스를 자연스럽게 복귀시키기 위함입니다.- 이 동작은
restoreFocus
옵션이 기본값인'previous'
일 때 활성화됩니다. 다른 방식으로 포커스를 제어하고 싶다면restoreFocus
에 직접 타겟 요소, 셀렉터, 또는 함수를 전달하세요.
Passing events to open()
open(event)
는 React 이벤트 또는 { currentTarget: HTMLElement }
형태의 객체를 받을 수 있습니다. 내부에서는 event.currentTarget
만 즉시 읽어 트리거 요소로 저장합니다.
// 1) 일반적인 사용: 원본 이벤트를 그대로 전달
button.onClick = async (e) => {
const r = await overlay.open(e)
}
// 2) 같은 트리거로 연속 오픈: currentTarget을 먼저 꺼내어 전달
button.onClick = async (e) => {
const target = e.currentTarget as HTMLElement
const r1 = await overlay1.open({ currentTarget: target, restoreFocusOnResolved: false })
if (r1.status !== 'resolved') {
return
}
const r2 = await overlay2.open({ currentTarget: target })
}
React 19에서는 예전의 이벤트 풀링이 제거되어 e.persist()
는 필요하지도, 의미도 없습니다. 비동기 흐름에서 이벤트 전체를 보관할 필요 없이, e.currentTarget
만 즉시 꺼내 로컬 변수로 전달하는 방식을 권장합니다.
Options
-
restoreFocus (default:
'previous'
)- 모달을 닫을 때 포커스를 어디로 되돌릴지 제어합니다.
'previous' | HTMLElement | () => HTMLElement | { selector: string }
- 권장값은
'previous'
:open(event)
호출 당시의 버튼 같은 트리거 요소로 자연스럽게 복귀합니다.
-
restoreFocusOnResolved / restoreFocusOnDismissed (default:
true
/true
)- 결과 종류에 따라 포커스 복귀 여부를 개별 제어합니다.
- 예: 사용자가 취소(dismiss)했을 때는 포커스를 굳이 되돌리지 않게 하려면
restoreFocusOnDismissed: false
.
-
dismissOnUnmount (default:
true
)- 컴포넌트가 언마운트되거나 조건부 렌더링으로 모달 루트가 사라질 때, 대기 중인 Promise가 영원히 걸리지 않도록 자동으로
{ status: 'dismissed', reason: 'unmount' }
로 정리합니다. - 라우트 전환/탭 이동/조건부 분기 해제 같은 상황에서 메모리·로직 누수를 방지합니다.
- 컴포넌트가 언마운트되거나 조건부 렌더링으로 모달 루트가 사라질 때, 대기 중인 Promise가 영원히 걸리지 않도록 자동으로
Focus control recipes
기본: 트리거로 포커스 복귀
const overlay = useAsyncFlow<boolean, 'close'>();
<button onClick={async (e) => {
const r = await overlay.open(e);
// 닫힌 뒤 트리거 버튼으로 포커스 복귀
}}>
Open
</button>
<Modal isOpen={overlay.isOpen} onClose={() => overlay.dismiss('close')} />
특정 요소로 복귀: selector
const overlay = useAsyncFlow({ restoreFocus: { selector: '#search-input' } })
동적으로 계산: 함수
const overlay = useAsyncFlow({
restoreFocus: () => document.querySelector('[data-focus="ok"]') as HTMLElement,
})
결과별로 포커스 복귀 제어
// 확인(Resolved)일 때만 포커스 복귀, 취소(Dismissed) 시에는 복귀하지 않음
const overlay = useAsyncFlow({
restoreFocusOnResolved: true,
restoreFocusOnDismissed: false,
})
포커스 복귀를 전부 비활성화
const overlay = useAsyncFlow({
restoreFocusOnResolved: false,
restoreFocusOnDismissed: false,
})
호출별로 다른 포커스 복구 전략 적용
const overlay = useAsyncFlow({
restoreFocusOnResolved: true,
restoreFocusOnDismissed: true,
})
// 일반적인 경우: 기본 설정 사용
const result1 = await overlay.open(event)
// 특정 호출에서만 포커스 복구 대상 변경
const result2 = await overlay.open(event, {
restoreFocus: { selector: '#custom-target' },
})
// 특정 호출에서만 dismiss 시 포커스 복구 비활성화
const result3 = await overlay.open(event, {
restoreFocusOnDismissed: false,
})
'previous'
는 open(event)
에 전달된 event.currentTarget
을 사용합니다. event
를 전달하지 않았거나 해당 요소가 더 이상 존재하지 않으면 포커스 복귀는 무시됩니다.
Dismissing on unmount
언마운트 시 자동 정리는 실서비스에서 매우 중요합니다. 라우트 전환, 조건부 렌더링 해제, 탭 닫힘 등에서 미처 닫지 못한 오버레이의 Promise가 남지 않도록 합니다.
const overlay = useAsyncFlow<{ ok: boolean }, 'close'>({
dismissOnUnmount: true, // 기본값이 true이므로 생략 가능
})
자동 정리에 의해 반환되는 reason
은 'unmount'
입니다.
라우트 전환 등 별도의 사유를 구분하고 싶다면 언마운트 전에 명시적으로 dismiss('route_change')
를 호출하는 것을 권장합니다.
function Page() {
const overlay = useAsyncFlow<{ ok: boolean }, 'route_change'>()
useEffect(() => {
const off = router.events.on('routeChangeStart', () => {
if (overlay.isOpen) overlay.dismiss('route_change' as const)
})
return () => off()
}, [overlay.isOpen])
// ...
}