@zoharyandrianome/crosshooks
v0.4.0
Published
Typed React hooks with platform adapters for web and React Native.
Maintainers
Readme
crosshooks
Typed React hooks with platform adapters for web, React Native iOS, and Android.
Typed React hooks for device features that normally behave differently on web, iOS, and Android. Each hook exposes one consistent API, so the same component can run everywhere without branching on the platform.
Install
pnpm add @zoharyandrianome/crosshooks
# or: npm install @zoharyandrianome/crosshooksReact 16.8+ is a peer dependency.
Hooks
usePWAInstallPrompt
Manage the browser's "Add to Home Screen" flow with a clean, imperative API — and a safe no-op on React Native, so the same install button just stays hidden on mobile.
import { usePWAInstallPrompt } from '@zoharyandrianome/crosshooks';
function InstallButton() {
const { canInstall, isInstalled, promptInstall } = usePWAInstallPrompt();
if (isInstalled || !canInstall) return null;
return (
<button
onClick={async () => {
const { outcome } = await promptInstall();
if (outcome === 'accepted') {
// user installed the app
}
}}
>
Install app
</button>
);
}Returns
| Field | Type | Description |
| --------------- | ---------------------------- | ------------------------------------------------------------------------------- |
| canInstall | boolean | The browser offered an install prompt and it's ready to show. |
| isInstalled | boolean | The app is already running as an installed PWA. |
| isSupported | boolean | false on React Native, during SSR, and where install prompts don't exist. |
| promptInstall | () => Promise<{ outcome }> | Shows the native prompt. outcome is accepted / dismissed / unavailable. |
usePushNotifications
Web push notifications
Drive the Web Push lifecycle — permission, subscription, and unsubscription — from one hook, and get a serializable subscription to send to your server. On React Native it's a safe no-op (native push rides on APNs/FCM via platform SDKs), so the same UI compiles everywhere.
This hook subscribes; it does not send. It runs on the client and only manages permission and the device subscription (
subscribe,unsubscribe,requestPermission). Actually delivering a notification happens from your backend — persist the subscription this hook returns, then push to it server-side (e.g. withweb-push, or via a provider's servers such as Firebase/OneSignal). Sending requires your VAPID private key, which must never ship to the browser.
import { usePushNotifications } from '@zoharyandrianome/crosshooks';
function NotificationsToggle() {
const { isSupported, isSubscribed, subscribe, unsubscribe } = usePushNotifications({
applicationServerKey: process.env.NEXT_PUBLIC_VAPID_KEY,
});
if (!isSupported) return null;
return (
<button
onClick={async () => {
if (isSubscribed) {
await unsubscribe();
} else {
const sub = await subscribe();
if (sub)
await fetch('/api/push/register', {
method: 'POST',
body: JSON.stringify(sub),
});
}
}}
>
{isSubscribed ? 'Disable notifications' : 'Enable notifications'}
</button>
);
}Requires an active service worker (for PushManager). Pass your VAPID public key
as applicationServerKey — Chromium browsers require it to subscribe.
Native push notifications
For the React Native implementation, you can opt for one of the providers below:
- Firebase
- OneSignal
- Expo
Firebase provider
Firebase is an optional provider for web, React Native iOS, and Android.
Follow the step-by-step Firebase setup guide for SDK installation, environment variables, service workers, and native files.
Import the provider from the /firebase subpath and pass it to
usePushNotifications. The bundler picks the web or native adapter
automatically — on native, config comes from google-services.json /
GoogleService-Info.plist, so firebaseProvider() takes no arguments there.
import { usePushNotifications } from '@zoharyandrianome/crosshooks';
import { firebaseProvider } from '@zoharyandrianome/crosshooks/firebase';
// Web: pass your Firebase config and VAPID key.
// React Native: call firebaseProvider() with no arguments.
const provider = firebaseProvider({
firebaseConfig: {/* apiKey, projectId, messagingSenderId, appId, … */},
vapidKey: process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY!,
});
function NotificationsToggle() {
const { isSupported, isSubscribed, subscribe, unsubscribe } = usePushNotifications({
provider,
});
if (!isSupported) return null;
return (
<button onClick={() => (isSubscribed ? unsubscribe() : subscribe())}>
{isSubscribed ? 'Disable notifications' : 'Enable notifications'}
</button>
);
}OneSignal provider
OneSignal is an optional provider for web, React Native iOS, and Android. Follow the step-by-step OneSignal setup guide for SDK installation, the App ID, service workers, and native files.
Import the provider from the /onesignal subpath and pass it to
usePushNotifications. The bundler picks the web or native adapter
automatically; both take the same { appId } config, and OneSignal manages the
subscription (opt-in / opt-out) and reports its subscription ID as the token.
import { usePushNotifications } from '@zoharyandrianome/crosshooks';
import { oneSignalProvider } from '@zoharyandrianome/crosshooks/onesignal';
const provider = oneSignalProvider({
appId: process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID!,
});
function NotificationsToggle() {
const { isSupported, isSubscribed, subscribe, unsubscribe } = usePushNotifications({
provider,
});
if (!isSupported) return null;
return (
<button onClick={() => (isSubscribed ? unsubscribe() : subscribe())}>
{isSubscribed ? 'Disable notifications' : 'Enable notifications'}
</button>
);
}Expo provider
Expo is an optional provider for web, React Native iOS, and Android. Follow the step-by-step Expo setup guide for SDK installation, the EAS project ID, service workers, and native files.
Import the provider from the /expo subpath and pass it to
usePushNotifications. The bundler picks the web or native adapter
automatically; both take the same { projectId } config, and Expo issues its
push token as the subscription. The EAS projectId is optional in managed
development and required in bare and production builds.
import { usePushNotifications } from '@zoharyandrianome/crosshooks';
import { expoProvider } from '@zoharyandrianome/crosshooks/expo';
const provider = expoProvider({
projectId: process.env.EXPO_PUBLIC_EAS_PROJECT_ID!,
});
function NotificationsToggle() {
const { isSupported, isSubscribed, subscribe, unsubscribe } = usePushNotifications({
provider,
});
if (!isSupported) return null;
return (
<button onClick={() => (isSubscribed ? unsubscribe() : subscribe())}>
{isSubscribed ? 'Disable notifications' : 'Enable notifications'}
</button>
);
}Returns
| Field | Type | Description |
| ------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| isSupported | boolean | On web, true when the browser supports FCM (Service Worker, PushManager, IndexedDB); on React Native, true on iOS and Android. |
| permission | 'default' \| 'granted' \| 'denied' | Current notification permission. |
| subscription | PushSubscription \| null | Serializable endpoint or provider token, or null. |
| isSubscribed | boolean | Whether a subscription is active. |
| requestPermission | () => Promise<PushPermission> | Prompts for permission and returns the result. |
| subscribe | () => Promise<PushSubscription\|null> | Ensures permission, then subscribes. null if refused/unsupported. |
| unsubscribe | () => Promise<boolean> | Cancels the active subscription. |
useOfflineSync
A persistent, connectivity-aware queue for mutations made while offline. Enqueue
writes as they happen; they are stored locally and drained through your onSync
handler when the device comes back online — with per-item attempt tracking and
order-preserving retries. On web, connectivity (navigator.onLine) and
persistence (localStorage) work out of the box; on React Native you inject
them, keeping one identical API on every platform.
import { useOfflineSync } from '@zoharyandrianome/crosshooks';
function TodoComposer() {
const sync = useOfflineSync<{ title: string }>({
onSync: async (todo) => {
// The network write that was unavailable offline.
await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo),
});
},
});
return (
<>
<button onClick={() => sync.enqueue({ title: 'Buy milk' })}>Add todo</button>
{!sync.isOnline && <p>Offline — {sync.pending.length} change(s) queued.</p>}
{sync.error && <button onClick={() => sync.flush()}>Retry</button>}
</>
);
}Resolve from onSync to mark an item synced; throw to keep it queued. A flush
processes items in enqueue order and stops on the first failure, so a failed
write never lets a later one jump ahead of it. Auto-flush on reconnect is on by
default.
On React Native, inject persistence and connectivity (crosshooks pulls in no native dependencies of its own):
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
import type { ConnectivitySource } from '@zoharyandrianome/crosshooks';
const connectivity: ConnectivitySource = {
getSnapshot: () => lastKnownOnline, // seed from NetInfo.fetch() at startup
subscribe: (onChange) =>
NetInfo.addEventListener((state) => onChange(state.isConnected ?? false)),
};
const sync = useOfflineSync({ onSync, storage: AsyncStorage, connectivity });Options
| Option | Type | Description |
| ---------------------- | ---------------------------- | ----------------------------------------------------------------------- |
| onSync | (payload, item) => unknown | Processes one queued item. Resolve to sync it; throw to retry later. |
| storageKey | string | Persistence key. Defaults to crosshooks:offline-sync. |
| storage | SyncStorage | Adapter. Defaults to localStorage on web; inject AsyncStorage native. |
| connectivity | ConnectivitySource | Online/offline source. Defaults to navigator.onLine on web. |
| autoFlushOnReconnect | boolean | Flush automatically when connectivity returns. Defaults to true. |
Returns
| Field | Type | Description |
| ----------- | --------------------------- | --------------------------------------------------------------- |
| isOnline | boolean | Whether the device is online. Optimistically true during SSR. |
| pending | SyncItem<T>[] | Queued items awaiting sync, in enqueue order. |
| isSyncing | boolean | true while a flush pass is running. |
| error | Error \| null | Most recent sync error, cleared once the queue drains. |
| enqueue | (payload) => SyncItem<T> | Queue a payload; schedules a flush when online. |
| flush | () => Promise<SyncResult> | Drain the queue now. No-ops while offline or already syncing. |
| remove | (id: string) => void | Drop a single queued item without syncing it. |
| clear | () => void | Discard every queued item without syncing. |
How the cross-platform build works
Each hook has two source implementations — *.web.ts and *.native.ts —
sharing one type definition. The package exposes two entry points and lets the
consumer's bundler pick:
"exports": {
".": {
"react-native": "./dist/index.native.js", // Metro resolves this
"import": "./dist/index.js", // web (ESM)
"require": "./dist/index.cjs" // web (CJS)
}
}The public type surface is identical on both platforms, so TypeScript catches misuse the same way everywhere.
Development
pnpm install
pnpm test # vitest (jsdom), incl. an SSR render test
pnpm typecheck # tsc --noEmit
pnpm lint # eslint (typescript-eslint + react-hooks)
pnpm format # prettier --write .
pnpm build # tsup → dual ESM/CJS + .d.ts
pnpm check:package # publint + are-the-types-wrong (validates the exports map)
pnpm size # size-limit (per-hook, tree-shaken)CI runs every one of these on push and PR, so the exports map, type resolution across ESM/CJS, and bundle size are all guarded automatically.
Releases are automated with changesets:
run pnpm changeset to record a change; merging the generated "Version Packages"
PR publishes to npm.
License
MIT © zohary
