jg-react
v0.6.0
Published
React hooks for the browser: online status, page visibility, Tailwind CSS breakpoints, and scroll thresholds, safe in server renders.
Maintainers
Readme
jg-react
React hooks for the browser: online status, page visibility, Tailwind CSS breakpoints, scroll thresholds, unsaved form changes, resend cooldowns, and light and dark themes, safe in server renders.
Install
npm install jg-reactIt needs React 19.2.4 or later.
Usage
import {
isBreakpointAtLeast,
useBreakpoint,
useDocumentVisibility,
useIsWindowScrolledPast,
useOnlineStatus,
} from 'jg-react';
export const useLayoutState = () => {
const isOnline = useOnlineStatus(); // the browser's guess, from its network interfaces
const isVisible = useDocumentVisibility(); // false while the tab is hidden
const breakpoint = useBreakpoint(); // 'xs' on a phone, then 'sm' to '2xl'
const isScrolled = useIsWindowScrolledPast(8);
return { isOnline, isVisible, isDesktop: isBreakpointAtLeast(breakpoint, 'lg'), isScrolled };
};| Hook | Returns | Re-renders when | In a server render |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------- |
| useOnlineStatus() | Whether the browser is online | It goes online or offline | true |
| useDocumentVisibility() | Whether the page is visible | Its tab is hidden or shown | true |
| useBreakpoint() | Tailwind CSS's breakpoint: xs below sm, up to 2xl | The viewport crosses a breakpoint | undefined |
| useIsWindowScrolledPast(pixels) | Whether the window is scrolled further than pixels | The answer changes, not every scroll | false |
| useFormChanges(options) | Whether a form's command differs from the last confirmed one, a submit-time check, and confirmChanges | The commands or the baseline change | Compared as usual |
| useResendCooldown(options) | Whether a resend must wait, the seconds left, and startCooldown | Every second while cooling down | { isCoolingDown: false, remainingSeconds: 0 } |
| useCopyFeedback(options?) | Whether a copy just succeeded, and copy(write) | A copy succeeds, and its feedback ends | isCopied: false |
| useFileUploadQueue(options) | The upload queue's items, summary, announcement, and controls | The queue changes | An empty queue |
| useResolvedTheme(preference) | light or dark, following the system while the preference is system | The system's color scheme changes, for system | dark for dark, otherwise light |
| useAppliedTheme(preference, options?) | The theme it sets as the light or dark class on <html> | As useResolvedTheme | As useResolvedTheme, without touching the document |
Keep a save button disabled until the form holds a real change, and a resend button waiting after each send:
import { useFormChanges, useResendCooldown } from 'jg-react';
const trim = ({ name }: { name: string }) => ({ name: name.trim() });
export const useProfileEditor = (saved: { name: string }, draft: { name: string }) => {
// a whitespace-only edit isn't a change, and the baseline advances only once a save succeeds
const { hasChanges, confirmChanges } = useFormChanges({
confirmedCommand: saved,
currentCommand: draft,
normalizeCommand: trim,
});
// the deadline survives a reload of the tab, apart from other accounts and actions
const resend = useResendCooldown({ durationSeconds: 60, cooldownKey: 'account-a:verify-email' });
return { canSave: hasChanges, confirmChanges, resend };
};The browser hooks read the browser through useSyncExternalStore, so every component sees the same value in the same render, and a server render gets a fixed value instead of failing. The breakpoints are Tailwind CSS's defaults, in rem, read through media queries, so they follow the browser's font size as CSS does. Prefer CSS for layout, and useBreakpoint for what CSS can't do, such as rendering a different component.
Copy feedback
useCopyFeedback shows that a copy worked, for a moment. copy takes the function that writes, such as jg-clipboard's copyText, and calls it at once, before awaiting anything, so the write stays inside the click that browsers require for clipboard access:
import { useCopyFeedback } from 'jg-react';
const { isCopied, copy } = useCopyFeedback({ durationSeconds: 1.4, onError: showCopyError });
const onClick = () => void copy(() => navigator.clipboard.writeText(inviteLink)); // resolves to whether it workedisCopied stays true for durationSeconds, DEFAULT_COPY_FEEDBACK_SECONDS (1.4) by default, and a new copy restarts it. A copy while another runs is ignored and resolves to false. A failed one resolves to false and calls the latest onError; copy never rejects. After unmount, a copy that settles changes nothing and calls nothing. A duration that isn't positive and finite throws INVALID_COPY_FEEDBACK_DURATION.
Dialog sessions
createDialogSession keeps one dialog's state outside React, so any code can open it and await what the user chose, while one host component renders it with useDialogSession:
import { createDialogSession, useDialogSession } from 'jg-react';
import { useDialogNavigationDismissal } from 'jg-react-router';
export const confirmDialog = createDialogSession<{ title: string }, boolean>();
// anywhere, such as a delete button's handler
const isConfirmed = await confirmDialog.open({ title: 'Delete the project?' }); // true, false, or undefined
// in the host component
const session = useDialogSession(confirmDialog); // the open session, or null
useDialogNavigationDismissal({
getSession: confirmDialog.getSnapshot, // a navigation closes the dialog
onDismiss: () => confirmDialog.close(),
});
const confirm = () => confirmDialog.close(true, session?.id);At most one session is open. Opening another resolves the open one's caller with undefined, or, with whileOpen: 'keep', resolves the new call with undefined, so no caller waits forever, and no promise rejects. A close that names its session's id does nothing once that session ended, so a late click on a closing dialog can't close a newer one. One frame after a close, focus returns to the element the session opened from, the focused one by default, when it's still in the page, enabled, and not inert, no other session opened, and focus is lost on the body; a session that replaces another returns focus where that one would have. A server render reads no open session.
File uploads
useFileUploadQueue runs a file upload queue without rendering anything, for a drop zone or a file input in the app's own markup:
import { createFileUploadAcceptValue, useFileUploadQueue } from 'jg-react';
const queue = useFileUploadQueue({
acceptedFileTypes: ['image/png', 'image/jpeg', '.heic'],
maxFileBytes: 10 * 1024 * 1024,
maxFiles: 4,
isPreviewEnabled: true,
uploadFile: (file, { signal, reportProgress, uploadId }) =>
uploadToStorage(file, { signal, onProgress: reportProgress, idempotencyKey: uploadId }),
});
// queue.addFiles(input.files), queue.items, queue.summary, queue.announcement,
// queue.cancelUpload(id), queue.retryUpload(id), queue.removeFile(id), queue.reset()
const accept = createFileUploadAcceptValue(queue.policy.acceptedFileTypes);- Checks: each selection is checked file by file, in a fixed order: empty, type, size, duplicate, count, and total size, with
existingFilescounted toward the limits. AvalidateFilefunction runs next, such as for an image's dimensions, and the built-in checks run again after it, since the queue may have changed meanwhile. Selections are checked one at a time. - Uploads: admitted files upload first in, first out, up to
maxConcurrentUploadsat once, 2 by default, or wait forstartUploadswithoutautoUpload. Progress is coalesced per frame and never goes backwards. At 100%, a file isprocessing: it waits for its result, can't be canceled, and survives removal and reset. - Cancel and retry: a canceled upload is aborted through its signal, and its late result is ignored, but its slot frees only when its promise settles. A retry keeps the item's
uploadIdand starts the nextattemptNumber. - One file: with
maxFiles1, the default, a new file replaces the current one only once it passes its checks, so a rejected replacement leaves the current file alone. - Cleanup: preview object URLs are revoked on remove, replace, reset, and unmount, and an error a callback throws goes to
reportErrorwithout stopping the queue. Shown again after an<Activity>hid it, files whose work stopped are canceled. - Text: every message and announcement comes from
DEFAULT_FILE_UPLOAD_MESSAGES, whichmessagesoverrides, andgetErrorFeedbackdecides what a failed upload shows,messages.uploadFailedby default.
A policy that isn't valid, such as a maxFileBytes that isn't a positive integer, throws INVALID_FILE_UPLOAD_POLICY, with a message for each field. resolveFileUploadPolicy, validateFileUploadCandidate, isFileUploadItemCancelable, and hasDraggedFiles work on their own too.
Light and dark themes
createThemeInitScript builds an inline script that applies the stored preference before the page paints, so a server render never flashes the wrong theme, and useAppliedTheme keeps the class on <html> in step once React runs:
import { createThemeInitScript, useAppliedTheme } from 'jg-react';
// jg-storage's createLocalStore('preferences') keeps { theme } under this key
const script = createThemeInitScript({ storageKey: 'jg-storage:preferences', field: 'theme' });
export const ThemeScript = ({ nonce }: { nonce: string }) => (
<script
nonce={nonce}
dangerouslySetInnerHTML={{ __html: script }}
/>
);
export const ThemeRoot = ({ preference }: { preference: 'light' | 'dark' | 'system' }) => {
useAppliedTheme(preference); // turns transitions off while the class changes
return null;
};Set color-scheme per class in your stylesheet, such as :root.dark { color-scheme: dark; }, so form controls and scrollbars follow the theme.
The preference and theme types, IThemePreference and IResolvedTheme, with THEME_PREFERENCES, in the order ['system', 'light', 'dark'], and resolveThemePreference, come from jg-site, which jg-react depends on, so a site and an app share one definition.
A component far from the one applying the theme, such as a chart that picks its colors in script, reads the theme the page shows with useDocumentTheme, without the user's preference: <html>'s dark class, then light, then the system's color scheme. It re-renders only when that theme changes, not for another class, and reads light on a server and while hydrating, as the init script's fallback does:
import { useDocumentTheme } from 'jg-react';
const lineColor = useDocumentTheme() === 'dark' ? '#93c5fd' : '#1d4ed8';Searching as the user types
useDebouncedSearch searches once the trimmed query has stayed the same for 0.3 seconds, aborting the previous search when the query changes, the search is disabled, or the component unmounts:
import { useDebouncedSearch } from 'jg-react';
const { results, isSearching, error, retry } = useDebouncedSearch({
query,
enabled: isSearchOpen, // optional
search: async (text, signal) => {
const response = await fetch(`/api/articles?q=${encodeURIComponent(text)}`, { signal });
return (await response.json()) as IArticle[];
},
});A search that settles after its query changed is ignored, even when search doesn't stop on the signal. The previous results stay while the next search runs, and an empty query or a disabled search clears everything. retry, such as for a Retry button after error, searches the current query again. A new search function on each render doesn't restart the wait.
Table of contents
useActiveHeading follows which heading the reader is at: the one the URL's hash names, on mount and on every hashchange, then, as the page scrolls, the last heading whose top passed topOffsetPx, the height of a sticky header, or the first one before any has:
import { useActiveHeading } from 'jg-react';
const { activeHeadingId, setActiveHeadingId } = useActiveHeading({ headingIds, topOffsetPx: 80 });
// <a href={`#${id}`} aria-current={activeHeadingId === id ? 'location' : undefined}
// onClick={() => setActiveHeadingId(id)}>Scrolling is followed with an IntersectionObserver, and only the hash where there's none. A new array with the same ids keeps the observer. A top offset that isn't a finite number of pixels of at least 0 throws INVALID_ACTIVE_HEADING_OFFSET.
Element width
useElementWidth turns an element's width into the value a component needs, such as how many columns fit, and re-renders only when that value changes, not on every pixel:
import { useElementWidth } from 'jg-react';
const { ref, value: columns } = useElementWidth((width) => Math.max(1, Math.floor(width / 280)));
// <ul ref={ref}>, laid out in `columns ?? 1` columnsIt measures the element's border box as soon as ref attaches it, before the browser paints, then follows it with a ResizeObserver, or the window's resize event where there's none. A width of 0, such as while the element is hidden with display: none, keeps the last value. Attaching another element measures that one, and detaching resets the value to undefined, as on a server. Return a primitive from the function, so an unchanged result never re-renders.
Documentation
The full API reference ships in the package as API.md, generated from its TSDoc for the installed version, and is at https://jg-packages.jesusgraterol.dev/packages/jg-react/.
Recipes for common jobs ship as RECIPES.md, with every block typechecked against the release, and are at https://jg-packages.jesusgraterol.dev/packages/jg-react/recipes/.
