Skip to content

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 useKeyManagement lives in End-to-End Encryption Architecture.


State-only hooks

Pure state containers. Subscribe and dispatch — nothing more.

HookPathPurposeReturnsUsed by
useUIsrc/hooks/useUI.jsUI-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
useToastsrc/hooks/useToast.jsEphemeral 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)
useMediaQuerysrc/hooks/useMediaQuery.jsSSR-safe matchMedia hook. Subscribes to MediaQueryList events; returns the current matches boolean.matches (boolean)App (drives isDesktopLayout, isTabletLayout, hasMouse)
useFocusRestoresrc/hooks/useFocusRestore.jsCaptures 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.

HookPathPurposeReturnsUsed by
useNoteActionssrc/hooks/useNoteActions.jsSingle-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
useNoteFormsrc/hooks/useNoteForm.jsComplete 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
useSettingssrc/hooks/useSettings.jsSettings 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.

HookPathPurposeReturnsUsed by
useAuthsrc/hooks/useAuth.jsMaster 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)
useKeyManagementsrc/hooks/useKeyManagement.jsE2EE 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
useUpgradeResumptionsrc/hooks/useUpgradeResumption.jsPost-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.

HookPathPurposeReturnsUsed by
useAnalyticssrc/hooks/useAnalytics.jsPostHog 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)
useEntitlementssrc/hooks/useEntitlements.jsFetches 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)
useUserProfilesrc/hooks/useUserProfile.jsFetches 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
usePiiReviewsrc/hooks/usePiiReview.jsShared client-side PII/injection review controller (SUR-308). Owns the single PiiReviewSheet state + guardrail telemetry contract (pathtranscribe | 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
useCameraStreamsrc/hooks/useCameraStream.jsBrowser 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
useLongPresssrc/hooks/useLongPress.jsDetects 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
useTurnstilesrc/hooks/useTurnstile.jsCloudflare 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 AuthControlsAuthScreen and UpgradeAuthGate)
useTermlyEmbedsrc/hooks/useTermlyEmbed.jsLoads the Termly policy-document embed script once per session; exposes containerRef for Termly to populate. Cleans up script on unmount.containerRefPolicyPage

Notes

  • imageStorageGate.js lives in src/hooks/ for colocation with useNoteForm but is a pure utility, not a React hook. Exports evaluateImageStorageGate(entitlements, baseBytes, sessionBytes, payloadBytes) — returns null (proceed, no event) or a gate object (fire would_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 call crypto.subtle directly in hooks or components. See E2EE Architecture § Encryption rules.
  • useAuth is the public façade for the encryption surface. It forwards the full useKeyManagement return 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.