@plott/webkit-react0.4.0
클라이언트 React 훅 — 제어/비제어 상태, 미디어 쿼리·움직임 줄이기, 디바운스·스로틀, ref 합치기·이벤트 합치기, 바깥 클릭, 인터벌·타임아웃, StrictMode 에서 한 번만, 하이드레이션 안전한 클라이언트 판정, 저장소·가시성·온라인 상태. 서버 진입점에서 닿지 않는다(client-only)
설치
pnpm
pnpm add @plott/webkit-react@0.4.0진입점(exports)
| import | 타입 | 파일 |
|---|---|---|
@plott/webkit-react | ./dist/index.d.ts | ./dist/index.js |
클라이언트 React 훅 모음입니다. 읽는 사람은 Next.js App Router 의 클라이언트 컴포넌트를 쓰는 프론트엔드 개발자이고, 이후 만들 UI 컴포넌트 라이브러리(uikit)의 바닥이 되는 헤드리스 동작을 여기서 갖습니다. React 가 아닌 도구(디바운스·재시도·Result 등)는 @plott/webkit-utils 에 있습니다.
모든 소스 파일이 "use client"; 로 시작합니다. 서버 컴포넌트에서 직접 import 하지 않고, "use client" 파일에서 씁니다. 서버 렌더링에서도 안전합니다 — 렌더 중에 window 를 읽지 않고, 서버 값은 아래 표의 «서버 값» 입니다.
진입점
| 진입점 | 어디서 | 내보내는 것 |
|---|---|---|
@plott/webkit-react | 클라이언트 컴포넌트(서버 렌더 포함) | 아래 «훅 목록» 전부 |
훅 목록
| 묶음 | 훅 | 서버·hydration 중의 값 |
|---|---|---|
| 상태 | useControllableState({ value, defaultValue, onChange }) | defaultValue |
| 이벤트 | useEventListener(target?, type, handler, { capture, passive, once, enabled }), useHotkeys({ "mod+k": handler }, { target, enabled, allowInEditable, mac }) | — |
| 포커스·스크롤 | useFocusTrap(ref, { enabled, restoreFocus, autoFocus }), getTabbableElements(container), useScrollLock(enabled) | — |
| 목록 이동 | useRovingTabIndex({ count, orientation, loop, rtl, index, onIndexChange, isDisabled }) → { index, setIndex, getItemProps(i) }, useActiveDescendant(같은 옵션) → { index, activeId, getItemId(i), onKeyDown }, nextRovingIndex(options) | — |
| 크기·누르기 | useElementSize() → [ref, { width, height }], useWindowSize({ defaultWidth, defaultHeight }), useLongPress(callback, { delay, moveTolerance }) | 0 / 기본 크기 |
| 숨김 | useOnHide(fn), useCloseOnHide(open, close), ResetOnHide | — |
| 미디어 | useMediaQuery(query, boolean | { defaultValue }), usePrefersReducedMotion(), usePrefersColorScheme() | defaultValue(false / "light") |
| 최신 값 | useEventCallback(fn), useLatest(value), usePrevious(value) | — |
| 타이밍 | useDebouncedValue, useDebouncedCallback, useThrottledCallback, useInterval, useTimeout | 처음 값 |
| ref·이벤트 | useMergedRef, mergeRefs, composeEventHandlers | — |
| 바깥 클릭 | useOnClickOutside(ref | ref[], handler, { events, enabled, ignore }) | (effect 만) |
| 클라이언트 | useIsClient, useHydrated, useMounted, useOnce, useIsomorphicLayoutEffect | false |
| 중복 방지 | useSingleFlight(fn) → [run, pending], useLatestRequest(fn) | — |
| 저장소 | useStorage(key, { area, defaultValue, serialize, parse }) → [value, set, remove] | defaultValue |
| 클립보드 | useCopyToClipboard({ resetMs }) → [copied, copy, status], copyText(text, { prefer }) | copied = false |
| 페이지 | useDocumentVisibility(), useOnline(), usePageLifecycle({ onHide, onShow }) | "visible" / true |
| 관찰자 | useIntersectionObserver(ref, options), useIntersectionObservers(options) → { observe(key), states }, useResizeObserver(ref, cb) | initialIsIntersecting(false) |
붙이기
tsx
"use client";import { useRef } from "react";import { useControllableState, useCopyToClipboard, useMediaQuery, useOnClickOutside, useSingleFlight, useStorage } from "@plott/webkit-react";
declare const api: { createOrder(form: FormData): Promise<void> };declare const form: FormData;
export function Menu(props: { open?: boolean; onOpenChange?: (open: boolean) => void }) { const [open, setOpen] = useControllableState({ value: props.open, defaultValue: false, onChange: props.onOpenChange });
const ref = useRef<HTMLDivElement>(null); useOnClickOutside(ref, () => setOpen(false), { enabled: open });
const [submit, pending] = useSingleFlight(() => api.createOrder(form));
const [theme, setTheme] = useStorage("theme", { defaultValue: "system" as "system" | "dark" | "light" });
const wide = useMediaQuery("(min-width: 768px)"); const [copied, copy] = useCopyToClipboard({ resetMs: 1500 }); return <div ref={ref} />;}| 훅 | 동작 |
|---|---|
useControllableState | 입력 컴포넌트가 제어든 비제어든 같은 규칙으로 값을 다룹니다. |
useOnClickOutside | 바깥을 누르면 닫습니다. Shadow DOM·드래그·터치 중복을 처리합니다. |
useSingleFlight | 제출 버튼을 두 번 눌러도 요청은 한 번입니다. |
useStorage | 탭 사이·컴포넌트 사이에 동기화하고, 저장소가 막혀도 던지지 않습니다. |
useOnce 와 useLatestRequest 는 StrictMode 에서 쓰는 곳입니다. useOnce 는 개발 모드의 이중 마운트에도 한 번만 실행하고, useLatestRequest 는 밀린 응답을 SupersededError 로 거절합니다. SupersededError 는 @plott/webkit-utils 에서 import 합니다(이 패키지는 다시 내보내지 않습니다).
tsx
import { useEffect } from "react";import { useLatestRequest, useOnce } from "@plott/webkit-react";import { isAbortError, SupersededError } from "@plott/webkit-utils";
declare const keycloak: { init(options: { onLoad: string }): Promise<boolean> };declare const setHits: (hits: unknown) => void;
export function Search({ q }: { q: string }) { useOnce(() => { void keycloak.init({ onLoad: "check-sso" }); });
const search = useLatestRequest((signal, q: string) => fetch(`/s?q=${q}`, { signal }).then((r) => r.json())); useEffect(() => { search(q).then(setHits, (e) => { if (e instanceof SupersededError || isAbortError(e)) return; throw e; }); }, [q, search]); return null;}기본으로 지키는 것
- 렌더 중에
window를 읽지 않습니다.useMediaQuery·useStorage·useOnline·useDocumentVisibility는useSyncExternalStore의 서버 스냅샷(위 표의 서버 값)으로 먼저 그리고 브라우저 값으로 바꿉니다. 서버 HTML 과 첫 그림이 같아 hydration 불일치 경고가 없습니다(서버 렌더·hydration 시험으로 확인). useIsClient/useHydrated는 hydration 중에만 false 입니다. 서버와 hydration 첫 그림은 false, 끝나면 true 입니다. 브라우저에서 새로 그리는 컴포넌트(클라이언트 이동)는 처음부터 true 라 불필요한 한 번의 깜박임이 없습니다.- StrictMode 의 두 번 마운트가 일을 두 번 하지 않습니다.
useOnce는 첫 마운트 effect 에서 한 번만 실행하고, 정리 함수는 StrictMode 의 모의 언마운트가 아니라 effect 가 정말 떼어질 때(언마운트,<Activity>숨김)만 부릅니다(keycloak.init이중 호출 방지).useSingleFlight는 effect 가 두 번 불러도 요청이 한 번입니다.useLatestRequest는 첫 요청을 취소하고 마지막만 살립니다. - 이중 제출이 상태에 기대지 않습니다.
useSingleFlight의pending은 화면 표시용이고, 막는 일은run이 직접 합니다 — 상태 반영 전 같은 틱의 두 번째 클릭도 같은 프라미스를 돌려받습니다. - 늦게 온 응답이 새 응답을 덮어쓰지 않습니다.
useLatestRequest가 앞선 요청의AbortSignal을 취소하고 앞선 프라미스를SupersededError로 거절합니다. 언마운트하면 진행 중 요청도 취소합니다. - 제어 ↔ 비제어 전환을 알립니다.
useControllableState는 개발 빌드에서value가 생기거나 사라지면 경고합니다. 같은 값으로의set은onChange를 부르지 않고, 함수형 갱신은 제어 모드에서도 같은 틱의 앞선 요청 값을 이어받아onChange가[1, 2]처럼 이어서 불립니다.defaultValue도value도 없이 쓰면 값 형은T | undefined입니다. <Activity>로 숨겼다 보여도 자원이 새지 않습니다. 숨기면 effect 처럼useOnce의 정리 함수를 부르고, 다시 보이면fn을 다시 실행합니다(정리 함수를 돌려주지 않는 일회성 초기화는 다시 실행하지 않습니다).- 포털로 그린 자식을 눌러도 부모가 닫히지 않습니다.
useOnClickOutside는 눌린 요소의 React 트리를 거슬러 올라가 판정합니다(React 내부 fiber 를 읽으므로, 못 찾으면 DOM 판정만 남습니다). 팝오버를 여닫는 버튼은ignore: [triggerRef]에 넣습니다 — 안 넣으면 누를 때 바깥 누름으로 닫힌 뒤 클릭이 다시 엽니다.ignore에는 CSS 선택자도 됩니다. - 인라인 옵션이 저장소 훅을 흔들지 않습니다.
useStorage의set은 키·영역이 같으면 같은 함수이고, 저장된 문자열이 같으면 읽은 값도 같은 객체입니다. 인라인defaultValue·parse·serialize를 넘겨도 effect 가 돌지 않습니다. 기본 해석기는__proto__같은 키를 지우는parseJson입니다. - 너무 긴 지연이 즉시 실행되지 않습니다.
useInterval·useTimeout은2^31-1ms 를 넘는 값을 예약하지 않고 개발 중 경고합니다. - 복사가 사용자 동작을 놓치지 않습니다.
navigator.clipboard가 없으면await없이 바로execCommand를 부릅니다.writeText가 거절된 뒤의 대체는 Safari 에서 막힐 수 있어, 그런 환경에는copyText(text, { prefer: "legacy" })(동기 경로 먼저)를 씁니다. - 나중에 마운트된 요소도 관찰합니다.
useIntersectionObserver(options)→[ref, state],useResizeObserver(callback)→ref의 콜백 ref 형태가 있고, ref 객체 형태도 렌더마다ref.current를 다시 확인합니다.once는 한 번에 온 항목 중 교차한 것을 놓치지 않습니다. - 자동 저장이 화면을 떠날 때 사라지지 않습니다.
useDebouncedCallback(fn, { wait, flushOnUnmount: true })는 언마운트 때 예약된 호출을 실행합니다.useDebouncedValue는Object.is로 비교하므로 렌더마다 새로 만드는 객체는useMemo로 안정시키거나equals를 줍니다. - 콜백 정체성이 effect 를 다시 돌리지 않습니다.
useEventCallback·useInterval·useTimeout·useDebounced/ThrottledCallback·usePageLifecycle·useOnClickOutside는 항상 최신 콜백을 부르되 구독·타이머를 다시 만들지 않습니다. 디바운스·스로틀은 언마운트하면 예약을 버립니다. - ref 를 합쳐도 사용자의 ref 가 사라지지 않습니다.
mergeRefs/useMergedRef는 객체·콜백 ref 를 모두 채우고, React 19 의 정리 함수를 존중하며 떼어낼 때 모두 비웁니다.composeEventHandlers는 사용자가preventDefault()하면 내 핸들러를 건너뜁니다(checkDefaultPrevented: false로 끔). - 바깥 클릭 판정이 틀리지 않습니다. Shadow DOM 은
composedPath로 판정합니다(host.contains는 안쪽을 바깥으로 봅니다). 누르기 시작이 안쪽이면 바깥에서 놓아도 바깥이 아닙니다. 터치가 만든mousedown중복은 한 번입니다. 누른 순간 DOM 에서 사라진 요소는 안쪽으로 칩니다. - 저장소가 막히거나 차도 던지지 않습니다.
useStorage는@plott/webkit-browser의safeStorage위에서 동작합니다. 접근이 막히면 메모리에만 두고, 가득 차면set이false를 돌려줄 뿐이며, 깨진 JSON·직렬화할 수 없는 값(BigInt)은 기본값/false입니다. 같은 탭은 즉시, 다른 탭은storage이벤트로 맞춥니다. navigator.clipboard가 없어도 복사합니다. 비보안 컨텍스트·WebView 에서 없거나 권한으로 거절되면 숨은textarea+execCommand("copy")로 물러서고(열린 대화상자 안에서는 그 안에, 포커스·선택은 되돌림), 어느 쪽도 안 되면false를 돌려줍니다.- 백그라운드·bfcache 를 놓치지 않습니다.
usePageLifecycle은visibilitychange와pagehide/pageshow(persisted)를 함께 듣고, 같은 가려짐은 한 번만 알립니다. 처음 로드의pageshow는 «복귀» 로 세지 않습니다. - IME 조합 중인 키를 단축키로 받지 않습니다.
useHotkeys는 한글 조합 중 Enter(isComposing,keyCode229)를 무시하고, 입력 칸에서는 수정키 없는 글자 단축키를 무시합니다(Escape와 Ctrl·Meta·Alt 조합은 받습니다).mod는 Mac 에서 Meta, 그 밖에서 Ctrl 입니다. 잘못된 조합 문자열은 렌더 중Error를 던집니다.useRovingTabIndex·useActiveDescendant도 조합 중·수정키 방향키를 무시합니다. - 모달이 포커스와 스크롤을 새게 두지 않습니다.
useFocusTrap은 Tab·Shift+Tab 을 안에서 돌리고, 닫히면 열기 전 요소로 포커스를 되돌립니다.useScrollLock은 겹쳐 잠가도 마지막이 풀 때 원래overflow·padding-right로 돌립니다. - 보이는 채로 있는 대상도 다시 알립니다.
useIntersectionObserver의observeKey가 바뀌면(목록이 늘 때 등) 관찰자를 다시 만들어 새 알림을 받습니다. 대상이 여럿이면useIntersectionObservers가 관찰자 하나로 키별 상태를 돌려줍니다. - 크기 알림을 버리지 않습니다.
useResizeObserver의 콜백은(entry | null, { width, height })를 받고, 항목이 비어 있어도 측정한 크기로 부릅니다.measureInitially를 주면 처음 크기를 먼저 줍니다. - 복사 실패를 알립니다.
useCopyToClipboard의status는"idle"·"copied"·"failed"이고resetMs뒤"idle"로 돌아옵니다. - API 가 없는 환경에서 조용합니다.
IntersectionObserver·ResizeObserver·matchMedia가 없으면 던지지 않고 기본값/무동작입니다.
그 밖
- 옵션과 기본값.
useMediaQuery:defaultValue(서버·hydration 중에 쓰는 값, 기본 false).usePrefersColorScheme(defaultValue = "light").useOnClickOutside:events(기본["pointerdown", "focusin"]),enabled(기본 true),ignore(ref 또는 CSS 선택자, 안쪽으로 치는 다른 곳).useStorage:area(기본"local"),defaultValue,serialize(undefined를 돌려주면 키를 지웁니다),parse(던지면defaultValue).useCopyToClipboard:resetMs(기본 2000, 0·Infinity 는 다음 복사까지 유지),prefer("async"기본 /"legacy").useIntersectionObserver:root,rootMargin,threshold,enabled(기본 true),once(기본 false),initialIsIntersecting(기본 false),observeKey(바뀌면 다시 관찰).useIntersectionObservers: 같은 옵션에서initialIsIntersecting만 뺍니다.useResizeObserver:box,enabled,measureInitially(기본 false).useDebouncedValue:wait,leading,trailing,maxWait,equals(기본Object.is).usePageLifecycle:onHide(한 번 가려지면 다시 보이기 전까지 한 번),onShow.useControllableState:value(주면 제어 모드,undefined는 주지 않음),defaultValue(함수면 처음 한 번만 부름),onChange.
- 시간 값이 잘못되면(
NaN·음수·2^31-1초과)useDebouncedCallback·useDebouncedValue·useThrottledCallback은 렌더 중RangeError를 던집니다. peerDependencies:react ^19.3.0. 의존:@plott/webkit-utils,@plott/webkit-browser.- 개발 경고 판정은
process.env.NODE_ENV를 번들러가 치환하는 것에 기댑니다(치환되지 않는 환경에서는 경고 없음). useMergedRef는 렌더마다 같은 개수의 ref 를 받아야 합니다(React 훅 의존성 규칙).