Skip to content

Custom Hooks

Reusable React hooks for common patterns.

Hook Organization

Hooks are organized in two locations:

Global Hooks (src/hooks/)

Reusable hooks shared across features, re-exported through the src/hooks/index.ts barrel:

hooks/
├── browser/             # useDebounce, useTimeout, useCountUp, usePullToRefresh,
│                        # usePageTitle, useViewBackNavigation, useDragHandleKeyboard/Props
├── files/               # useFileCompression, useFileProcessingStates,
│                        # useFileStatusCallback, useR2Upload
├── modals/              # useDeleteConfirmation, useEntityModal, useGuardedModal, useModalStack
├── optimistic/          # useOptimisticCreate, useOptimisticUpdate, useOptimisticDelete,
│                        # useOptimisticPatch, optimisticMutationHelpers
├── useAppEvents.ts      # App lifecycle events
├── useApplyLanguage.ts
├── useAutoUpdateOnIdle.ts
├── useFilters.ts
├── usePaginatedParams.ts
└── index.ts             # Barrel — all consumers import from "@/hooks"

Page Hooks (src/features/{feature}/hooks/)

Feature-specific orchestration hooks that compose global hooks:

features/
├── budget/hooks/
│   ├── useBudgetTabs.ts           # Tab state, URL sync
│   ├── useBudgetModals.ts         # Modal orchestration
│   ├── useBudgetFilters.ts        # Filter state
│   ├── useTransactionSubmit.ts    # Form submission
│   ├── useRecurringForm.ts        # Recurring transaction form state
│   ├── useAddButton.ts            # FAB configuration per tab
│   ├── useMonthNavigation.ts      # Month/year navigation state
│   ├── useRecurrenceLabels.ts     # Recurrence display labels
│   └── useCategorySelectOptions   # Category dropdown options
├── map/hooks/
│   ├── useMarkerFormData.ts       # Marker detail + images loading for edit mode
│   ├── useMarkerFormSubmit.ts     # Create/edit submission, image upload orchestration
│   ├── useMarkerFormClose.ts      # Unsaved changes handling, form/image state reset
│   ├── useMarkerImages.ts         # Marker image state management
│   ├── useMarkerWithImages.ts     # Create/update marker with uploads
│   └── useMoveMode.ts             # Map move mode state
└── notes/hooks/
    ├── useNoteSelection.ts        # Selected note state
    ├── useNoteSubmit.ts           # Form submission
    └── useNoteItemDnd.ts          # Drag-and-drop

When to use which: - Global hooks: Generic, reusable across 2+ features - Page hooks: Feature-specific orchestration, combines multiple hooks/state for one page

Overview

flowchart TB
    subgraph Data["Data/API Hooks"]
        A[useDebounce]
        B[usePaginatedParams]
        P[useFilters]
    end

    subgraph Files["File Hooks"]
        C[useFileCompression]
        D[useR2Upload]
        Q[useFileStatusCallback]
        R[useFileProcessingStates]
    end

    subgraph Optimistic["Optimistic Mutation Hooks"]
        E[useOptimisticCreate]
        F[useOptimisticUpdate]
        G[useOptimisticDelete]
        L[useOptimisticPatch]
    end

    subgraph UI["UI Hooks"]
        H[useTimeout]
        I[useCountUp]
        J[usePullToRefresh]
        K[useDragHandleKeyboard]
        S[usePageTitle]
    end

    subgraph Modal["Modal Hooks"]
        M[useDeleteConfirmation]
        N[useEntityModal]
        O[useGuardedModal]
        T[useModalStack]
    end

Data Hooks

useDebounce

Debounce value changes.

const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, TIMING.DEBOUNCE_MS);

useEffect(() => {
  fetchResults(debouncedSearch);
}, [debouncedSearch]);

usePaginatedParams

URL-synced search, page, filter, and sort state for paginated tables. Search is debounced via useTimeout with TIMING.DEBOUNCE_MS.

const { search, debouncedSearch, page, filter, sort, setSearch, clearSearch, setPage, setFilter, setSort } =
  usePaginatedParams<"all" | "pending">({
    filter: { key: "status", defaultValue: "all" },
    defaultSort: { key: "createdAt", direction: "desc" },
  });

State is read from and written to URL search params (search, page, sortBy, sortDir, plus the configured filter key), so pagination state survives refresh and navigation.

useFilters

Generic in-memory filter state with reset tracking.

const { filters, setFilter, setFilters, resetFilters, resetFilter, hasActiveFilters } = useFilters({
  category: null,
  onlyActive: false,
});

setFilter("onlyActive", true);

File Hooks

useFileCompression

Client-side image compression via browser-image-compression (Web Worker with non-worker fallback). Validates MIME type, extension (ENV.ACCEPTED_IMAGE_EXTENSIONS), and size (ENV.MAX_FILE_SIZE_MB input, ENV.API_FILE_LIMIT_MB output) before/after compressing.

const { compressFile, compressFiles } = useFileCompression();

const compressed = await compressFile(file, abortController.signal);
compressed.file;     // Compressed File
compressed.dataUrl;  // Preview data URL

useR2Upload

Direct PUT upload to Cloudflare R2 via a presigned URL (HTTPS enforced).

const { uploadToR2, state, reset } = useR2Upload();

await uploadToR2(uploadUrl, compressed.file);
state.isUploading; // boolean
state.progress;    // 0 or 100
state.error;       // string | null

useFileStatusCallback

Tracks backend file processing status via the fileKeys.status(fileId) query and a query cache subscription. Fires callbacks when status reaches Completed or Failed.

const { startTracking, stopTracking, isProcessing } = useFileStatusCallback({
  onCompleted: () => refetchImages(),
  onFailed: () => showErrorToast(t(TranslationKeys.shared.error.imageProcessingFailed)),
});

startTracking(fileId);

useFileProcessingStates

Manages an array of FileProcessingState items (compression progress, upload progress, abort controllers) for multi-file upload UIs. Used by the marker form image workflow.

Optimistic Mutation Hooks

Located in src/hooks, built on shared helpers in optimisticMutationHelpers.ts (cancelAndSnapshot, restoreOnError, showSuccessMessage, invalidateOnSettled). Provide instant UI feedback with automatic rollback on error: useOptimisticCreate/useOptimisticUpdate/useOptimisticDelete for single-list CRUD, useOptimisticPatch for any other single-query patch (reorders, toggles, detail-object updates).

useOptimisticCreate

Adds a temporary item to the list immediately, swaps it for the server response on success, rolls back on failure.

const createMutation = useOptimisticCreate<CreateRequest, Response, ListItem>({
  mutationFn: (data) => executeQuery(postApiV1Items({ body: data })),
  listQueryKey: itemKeys.list(),
  createTempItem: (data, tempId, previousData) => ({ id: tempId, order: previousData?.length ?? 0, ...data }),
  mapResponseToItem: (response) => response,
  getItemId: (item) => item.id,
  insertPosition: "start",
  transformList: (list) => sortByName(list),
  successMessage: t(TranslationKeys.shared.toast.created),
  invalidateOnSuccess: [itemKeys.report()],
  meta: { invalidateOnConflict: [itemKeys.all] },
});

insertPosition, transformList, successMessage, invalidateOnSuccess, and meta are optional. transformList runs after the optimistic insert (e.g. to keep the list sorted); meta is passed through to the underlying mutation (e.g. for invalidateOnConflict).

useOptimisticUpdate

Updates the item in the list immediately, replaces it with the server response on success.

const updateMutation = useOptimisticUpdate<UpdateRequest, Response, ListItem>({
  mutationFn: ({ id, data }) => executeQuery(putApiV1ItemsById({ path: { id }, body: data })),
  listQueryKey: itemKeys.list(),
  getItemId: (item) => item.id,
  applyUpdate: (item, data) => ({ ...item, ...data }),
  mapResponseToItem: (response) => response,
});

Also accepts optional transformList, successMessage, invalidateOnSuccess, and meta.

useOptimisticDelete

Removes the item from the list immediately.

const deleteMutation = useOptimisticDelete<ListItem>({
  mutationFn: (id) => executeMutation(deleteApiV1ItemsById({ path: { id } })),
  listQueryKey: itemKeys.list(),
  getItemId: (item) => item.id,
});

Also accepts optional successMessage, invalidateOnSuccess, and meta.

useOptimisticPatch

Generic single-query patch for anything that is not plain list CRUD: whole-list transforms (reorder, set-default) and detail-object patches (note items). The patched query key is derived from the mutation variables.

const toggleMutation = useOptimisticPatch<ToggleVariables, NoteItemResponse[], NoteDetailResponse>({
  mutationFn: ({ noteId, itemId }) => executeQuery(patchApiV1NotesItems({ path: { noteId, itemId } })),
  queryKey: ({ noteId }) => notesKeys.detail(noteId),
  applyPatch: (note, { itemId }) => ({
    ...note,
    items: note.items?.map((item) => (item.noteItemId === itemId ? { ...item, isChecked: !item.isChecked } : item)),
  }),
  applyResponse: (note, items) => ({ ...note, items }),
  mutationKey: ["noteItems"],
});
  • applyPatch applies the optimistic change to the cached data (pure function)
  • applyResponse (optional) re-patches the cache with the server response on success
  • mutationKey (optional) registers the mutation for queryClient.isMutating({ mutationKey }) guards
  • Also accepts optional successMessage, invalidateOnSuccess, and meta

All hooks automatically: - Cancel in-flight queries and snapshot previous state before mutation - Apply the optimistic update immediately - Roll back silently on failure (error toasts come from the global MutationCache handler) - Show an optional success toast - Invalidate the patched query key (plus optional invalidateOnSuccess keys) on settled

For hand-rolled optimistic mutations that touch multiple cached queries (e.g. notes lists, paginated transactions), use the shared helpers in src/api/cacheUtils.ts: updateAllMatchingQueries applies an updater to every array cache matching a key prefix, and restoreQueries restores a getQueriesData snapshot on rollback.

UI Hooks

useTimeout

Single managed timeout with automatic cleanup on unmount.

const timer = useTimeout();

timer.set(() => showNotification(), TIMING.DEBOUNCE_MS);
timer.clear();

useCountUp

Animated number count-up with easing, respecting prefers-reduced-motion.

const displayed = useCountUp(totalAmount, { precision: 2 });

useAutoUpdateOnIdle

Applies a dismissed PWA update automatically after the user has been idle for PWA.AUTO_UPDATE_IDLE_MS. Reads state from usePwaUpdateStore; takes no arguments.

useAutoUpdateOnIdle();

usePageTitle

Sets document.title to "{title} | Unicorn Trails" (or the app name alone).

usePageTitle(t(TranslationKeys.map.title));

Input Hooks

usePullToRefresh

Mobile pull-to-refresh gesture. Requires a scroll container ref; returns touch handlers and an indicator ref (see PullToRefreshBox/PullToRefreshIndicator components).

const { pullHandlers, indicatorRef, isRefreshing } = usePullToRefresh({
  onRefresh: async () => {
    await refetch();
  },
  scrollRef,
});

return (
  <div {...pullHandlers}>
    <div ref={indicatorRef}>...</div>
    ...
  </div>
);

useDragHandleKeyboard

Keyboard support for @dnd-kit drag handles (accessibility). Wraps the dnd-kit listeners so Enter/Space do not scroll the page.

const { handleKeyDown } = useDragHandleKeyboard(listeners);

return <DragHandle {...listeners} onKeyDown={handleKeyDown} />;

useDragHandleProps(attributes, listeners) bundles attributes, listeners, onKeyDown, role="button", and tabIndex={0} into a single spreadable props object.

useDeleteConfirmation

Manages delete confirmation dialog state. Extracts common pattern used across list views.

const deleteConfirmation = useDeleteConfirmation();

return (
  <>
    <ItemList onDelete={deleteConfirmation.openDelete} />
    <ConfirmDeleteDialog
      open={deleteConfirmation.isOpen}
      onConfirm={() => deleteConfirmation.confirmDelete(deleteMutation.mutate)}
      onCancel={deleteConfirmation.cancelDelete}
    />
  </>
);

Returns: - deletingId — ID of item pending deletion (or null) - isOpen — Whether confirmation dialog is open - openDelete(id) — Open confirmation for specific item - confirmDelete(callback) — Execute callback with deletingId, then close - cancelDelete() — Close without action

useEntityModal

Create/edit modal state for an entity, with optional store-driven open state and delete dialog state.

const modal = useEntityModal<Marker>({ withDeleteDialog: true });

modal.openCreate();
modal.openForEdit(marker);   // openEdit is an alias
modal.close();

modal.isOpen;         // boolean
modal.isEditMode;     // editingEntity !== null
modal.editingEntity;  // Marker | null

modal.openDelete(marker);
modal.isDeleteOpen;
modal.entityToDelete;
modal.closeDelete();

Options: storeIsOpen / onStoreClose let a Zustand store control the open state; withDeleteDialog enables the delete dialog state.

useGuardedModal

Guards every close path of a modal (close button, backdrop, browser back button) behind an unsaved-changes dialog. Registers with the modal stack internally.

const { handleClose, dialogProps } = useGuardedModal({
  id: "my-modal",
  open,
  isDirty: isDirty && !isSubmitting,
  onClose: () => {
    reset();
    onClose();
  },
});

return (
  <>
    <Dialog open={open} onClose={handleClose}>...</Dialog>
    <UnsavedChangesDialog {...dialogProps} />
  </>
);
  • handleClose — close request handler; opens the unsaved-changes dialog when dirty, otherwise calls onClose
  • dialogProps — spreadable props for UnsavedChangesDialog (open, onDiscard, onCancel, modalStackId)
  • Browser back button goes through the same guard via the internal useModalStack registration

useModalStack

Registers an open modal with the global modal stack store so the browser back button closes the topmost modal instead of navigating. Optional beforeClose can veto the close (used for dirty forms).

useModalStack({ id: "marker-form", open, onClose, beforeClose });

useViewBackNavigation({ id, active, onBack }) is a thin wrapper for full-screen views that should behave the same way.

Hook Composition

Page hooks compose global hooks for complex flows. The marker form is the reference example:

const { markerDetails, isLoading, markerImages } = useMarkerFormData({ open, isEditMode, markerId });

const { handleFormSubmit, isSubmitting } = useMarkerFormSubmit({
  target, markerDetails, onClose, reset, isDirty, getValues, handleSubmit,
  setFileStates, deletedImageIds, resetImageState, getCompressedFiles, getImageOrders,
  isProcessingImages,
});

useMarkerFormSubmit internally composes useCreateMarkerWithImages/useUpdateMarkerWithImages (which orchestrate marker mutations, useR2Upload, and image confirm calls) with image state from useFileProcessingStates.