React Hooks Registry
React Hooks Registry
The Surfc PWA’s hooks are its brain. Almost every cross-cutting concern —
authentication, sync, encryption, gating, telemetry — is exposed to the UI
through a hook in surfc/src/hooks/. This page is the single reference for
each hook’s purpose, return shape, and call sites.
The conventional split
Hooks fall into one of four categories. The category determines where new behaviour belongs and how heavy the hook is allowed to get.
- State-only. Pure React state for UI toggles and ephemeral data. No network, no Dexie, no encryption. If a hook starts making API calls, it has outgrown this bucket.
- Action. Single-domain CRUD. One hook per domain (notes, settings, etc.).
Reads and writes Dexie + Supabase via
cloudWrite. May orchestrate a small pipeline (e.g. transcription → safety check → save) but does not own session or device state. - Lifecycle orchestrator. Master state machines that coordinate multiple
domains. Mounted exactly once at the app root (
App.jsx). Manage session, sync, encryption, telemetry funnels. The most expensive hooks in the codebase. - Feature & integration. Domain-specific integrations that did not slot cleanly into the three above — usually because they wrap a single external surface (PostHog, Cloudflare Turnstile, Termly, browser camera).
Convention. If a hook is named in
surfc/CLAUDE.md, it must have a row here. When you add a new hook, add the row in the same PR.
Sibling docs. Component-level locations and purposes for the non-hook code paths above live in Components Registry. The encryption story for
useKeyManagementlives in End-to-End Encryption Architecture.
State-only hooks
Pure state containers. Subscribe and dispatch — nothing more.
| Hook | Path | Purpose | Returns | Used by |
|---|---|---|---|---|
useUI | src/hooks/useUI.js | UI-toggle state only: selected idea, idea search, settings + linked-devices modals, lightbox image, ideas sidebar. Navigation is not owned here — components call useNavigate() directly. | { selectedIdea, ideaSearch, setIdeaSearch, showSettings, showLinkedDevices, lightboxImg, ideasSidebarOpen, ideaCounts, isCustom, notesForIdea, openIdeasSidebar, closeIdeasSidebar, selectIdea, syncSelectedIdea, filterByIdea, clearSelectedIdea, openSettings, closeSettings, openLinkedDevices, closeLinkedDevices, openLightbox, closeLightbox } | App, IndexScreen, IdeasSidebar, IdeaDetail, NoteForm, NoteCard |
useToast | src/hooks/useToast.js | Ephemeral notification state with auto-dismiss. Displays success/error messages. | { toast, showToast } | 5+ components — see grep (App via useAuth, CaptureScreen, NoteForm, ProfileScreen, SettingsModal, AddIdeaSheet, CustomIdeaRow, YourPlanSection) |
useMediaQuery | src/hooks/useMediaQuery.js | SSR-safe matchMedia hook. Subscribes to MediaQueryList events; returns the current matches boolean. | matches (boolean) | App (drives isDesktopLayout, isTabletLayout, hasMouse) |
useFocusRestore | src/hooks/useFocusRestore.js | Captures the focused DOM element at modal/sheet open and restores focus on close. Strict-Mode-safe via a !previousFocusRef.current guard. | void (side-effect; cleanup function restores focus) | AddIdeaSheet, CustomIdeaRow, NoteActionOverlay, IdeaActionOverlay, SettingsModal, PiiReviewSheet |
Action hooks
Single-domain CRUD. Each owns one slice of user data and exposes stable methods.
| Hook | Path | Purpose | Returns | Used by |
|---|---|---|---|---|
useNoteActions | src/hooks/useNoteActions.js | Single-note operations: edit, rediscover ideas (calls AI), soft-delete. Fires would_have_blocked telemetry behind the SUR-327 Phase A re_discover gate. Since SUR-308, rediscoverIdeas runs the client-side PII review via the injected usePiiReview controller (3rd arg) before the AI call — Cancel aborts, Redact sends asterisked text to the API only (stored note unchanged), Send proceeds. | { editNote, rediscoverIdeas, deleteNote, rediscoveringId } | App, NoteActionOverlay, NoteCard, IdeaDetail |
useNoteForm | src/hooks/useNoteForm.js | Complete note creation flow: image capture → transcription → AI idea discovery → safety checks (PII review) → book detection → image upload (with SUR-327 storage gate) → save. Owns the bulk of capture-screen state. | Large object (~50 keys). Highlights: noteText, setNoteText, noteBook, notePage, noteChapter, pendingTags, capturedImage, aiLoading, transcribeLoading, discoverIdeas, tagIdea, createAndTagIdea, saveNoteForm, handleDeleteNote (the PII-review sheet state moved to the injected usePiiReview controller in SUR-308) | App, CaptureScreen, NoteForm, AddIdeaSheet |
useSettings | src/hooks/useSettings.js | Settings panel: custom-ideas CRUD with dedupe, library export, library import (merge or replace). Wires idea name changes into pending-note tag updates. | { newIdeaName, setNewIdeaName, newIdeaDesc, setNewIdeaDesc, importResult, pendingImport, importRef, addCustomIdea, handleEditCustomIdea, handleDeleteCustomIdea, handleExport, handleImportFile, confirmMerge, confirmReplace, cancelImport } | App, SettingsModal, ProfileScreen, CustomIdeaRow |
Lifecycle orchestrators
Master state machines mounted at App.jsx. Coordinate multiple domains.
Heavy by design — should never be called from a leaf component.
| Hook | Path | Purpose | Returns | Used by |
|---|---|---|---|---|
useAuth | src/hooks/useAuth.js | Master auth/sync/crypto orchestration. Owns session, online status, Dexie/Supabase sync, plaintext note/book/idea state, encryption-state forwarding from useKeyManagement, sign-out with outbox confirmation, account deletion. Fires SUR-367 funnel events (signup_verified, app_opened, first_note). | Large object. Auth surface: session, setSession, online, syncing, syncStatus, ready, books, notes, customIdeas. Sync: cloudWrite, syncFromCloud. Crypto (forwarded from useKeyManagement): passkeyEnrolled, encryptionReady, migrating, transferPin, deviceBlobs, fetchDeviceList, removeDevice, …. Lifecycle: handleSignOut, confirmSignOutDiscardingChanges, handleDeleteAccount, finalizeAccountDeletion. | App (root wiring) |
useKeyManagement | src/hooks/useKeyManagement.js | E2EE Master Key lifecycle in React. Owns: enrolment (generate MK, wrap, upload blob), unlock (deriveEncryptionKey), legacy migration (re-encrypt all notes), sign-in probes (detect blobs / encrypted notes), sessionStorage + Dexie blob cache restoration (tryEagerKeyRestore), multi-device add (new passkey + re-wrap), device transfer (create/redeem PIN blob), device list fetch + removal (SUR-233). fetchDeviceList is useCallback-memoised on session?.user?.id so consumers see a stable identity. | Large object. Encryption state: passkeyEnrolled, encryptionReady, encryptionCheckPending, migrating, migrationProgress, migrationTotal, migrationError. Multi-device: addDeviceStatus, addDeviceError, activeWrapperCount. Transfer: transferPin, transferStatus, transferError, transferRedeemStatus, transferRedeemError. Devices: currentDeviceBlobId, deviceBlobs, deviceListStatus, removeDeviceStatus, removeDeviceError, fetchDeviceList, removeDevice. Methods: initEncryptionFromPrf, deriveEncryptionKey, addDeviceWrapper, createDeviceTransfer, redeemDeviceTransfer, tryEagerKeyRestore, probeCloudEnrollment, resetEncryptionState, runSignInEncryptionCheck. | useAuth (orchestrator); state forwarded to App and downstream via useAuth |
useUpgradeResumption | src/hooks/useUpgradeResumption.js | Post-auth upgrade-flow resumption. On null → user session transition (OAuth/email magic-link cold redirect), checks sessionStorage for a fresh upgrade intent and navigates to /upgrade?interval=…&ref=redirect_resume to continue Stripe Checkout. | void (side-effect; navigates via useNavigate()) | App |
Feature & integration hooks
Domain-specific integrations that wrap a single external surface or expose a niche capability. Smaller surface area than orchestrators.
| Hook | Path | Purpose | Returns | Used by |
|---|---|---|---|---|
useAnalytics | src/hooks/useAnalytics.js | PostHog wrapper. capture(event, properties), captureWouldHaveBlocked(featureKey, { tier, currentValue, limit }) (SUR-327 Phase A gates), identifyUser, resetUser. No-op when VITE_POSTHOG_KEY is absent (local dev). | { posthog, capture, captureWouldHaveBlocked, identifyUser, resetUser } | useAuth, useNoteForm, useNoteActions, useSettings, AuthScreen, UpgradeAuthGate, ProfileScreen, UpgradeSuccessRoute, EmailSignInFlow (5+ critical-path consumers) |
useEntitlements | src/hooks/useEntitlements.js | Fetches the resolved Entitlements shape from the me-entitlements Edge Function (SSoT via getResolvedEntitlements() in supabase/functions/_shared/entitlements.ts). Module-level cache keyed by user_id. refresh() is called by UpgradeSuccessRoute post-Stripe (SUR-345) so the UI flips to Pro without a sign-in cycle. | { entitlements, loading, error, refresh } | App, UpgradeRoute, UpgradeSuccessRoute, useNoteForm (gate predicates) |
useUserProfile | src/hooks/useUserProfile.js | Fetches the user_profiles row from Supabase via fetchUserProfile(userId). The settled flag distinguishes “still loading” from “no row” (SUR-345 grace-user routing). Read by useNoteForm for image_storage_bytes_used (SUR-327 storage gate). | { userProfile, refreshUserProfile, userProfileSettled } | App, UpgradeRoute, useNoteForm |
usePiiReview | src/hooks/usePiiReview.js | Shared client-side PII/injection review controller (SUR-308). Owns the single PiiReviewSheet state + guardrail telemetry contract (path ∈ transcribe | discover | rediscover). Constructed once in App, injected into both note hooks: useNoteForm uses the low-level awaitPiiReview/fireInjectionTelemetry primitives inline; useNoteActions uses the high-level reviewBeforeSend(text, { path }) orchestrator (returns { action, textToSend }; redact never mutates the stored note). | { piiReview, awaitPiiReview, fireInjectionTelemetry, reviewBeforeSend } | App, useNoteForm, useNoteActions |
useCameraStream | src/hooks/useCameraStream.js | Browser camera lifecycle. Manages getUserMedia() request, stream setup, tab-visibility pause/resume, photo capture. Status transitions: idle → requesting → streaming (or denied/unsupported/error). | { videoRef, status, error, start, stop, takePhoto } | CaptureScreen |
useLongPress | src/hooks/useLongPress.js | Detects a long-press gesture (500 ms hold, 10 px motion threshold). Returns touch handlers + onContextMenu for desktop test-ability. | { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, onContextMenu } | NoteCard, IdeaDetail |
useTurnstile | src/hooks/useTurnstile.js | Cloudflare Turnstile widget loader + renderer (SUR-361). Loads script once per session, renders explicit widget into containerRef. Appearance is interaction-only (invisible until challenge needed). Mounted inside the email-OTP sub-tree only because signInWithOAuth silently drops captchaToken. | { containerRef, token, error, reset } | EmailSignInFlow (nested in AuthControls → AuthScreen and UpgradeAuthGate) |
useTermlyEmbed | src/hooks/useTermlyEmbed.js | Loads the Termly policy-document embed script once per session; exposes containerRef for Termly to populate. Cleans up script on unmount. | containerRef | PolicyPage |
Notes
imageStorageGate.jslives insrc/hooks/for colocation withuseNoteFormbut is a pure utility, not a React hook. ExportsevaluateImageStorageGate(entitlements, baseBytes, sessionBytes, payloadBytes)— returnsnull(proceed, no event) or a gate object (firewould_have_blocked, still proceed). Extracted so the SUR-327 Phase A predicate is unit-testable.- All encryption operations route through
keyManager.getEncryptionKey()+noteEncryption.encryptText/decryptText— never callcrypto.subtledirectly in hooks or components. See E2EE Architecture § Encryption rules. useAuthis the public façade for the encryption surface. It forwards the fulluseKeyManagementreturn value upward so leaf components never call the orchestrator directly. This keeps the orchestrator mounted exactly once.- SUR-327 Phase A (observational gates).
useAnalytics.captureWouldHaveBlocked()is fired at four gate sites (re_discover,custom_ideas,max_devices,image_storage) but actions still proceed. SUR-328 will turn enforcement on.
Evidence gathered from source files only, per AGENTS.md.