본문으로 건너뛰기
언어

@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, useIsomorphicLayoutEffectfalse
중복 방지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, keyCode 229)를 무시하고, 입력 칸에서는 수정키 없는 글자 단축키를 무시합니다(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 훅 의존성 규칙).