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"],
});
applyPatchapplies the optimistic change to the cached data (pure function)applyResponse(optional) re-patches the cache with the server response on successmutationKey(optional) registers the mutation forqueryClient.isMutating({ mutationKey })guards- Also accepts optional
successMessage,invalidateOnSuccess, andmeta
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.
useCountUp¶
Animated number count-up with easing, respecting prefers-reduced-motion.
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.
usePageTitle¶
Sets document.title to "{title} | Unicorn Trails" (or the app name alone).
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.
Modal Hooks¶
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 callsonClosedialogProps— spreadable props forUnsavedChangesDialog(open,onDiscard,onCancel,modalStackId)- Browser back button goes through the same guard via the internal
useModalStackregistration
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).
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.
Related¶
- API Layer — TanStack Query hooks
- State Management — Zustand stores
- Forms — Form handling
- Image Upload — File hook pipeline