npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zoharyandrianome/crosshooks

v0.4.0

Published

Typed React hooks with platform adapters for web and React Native.

Readme

crosshooks

Typed React hooks with platform adapters for web, React Native iOS, and Android.

CI npm live demo license

Browse the full documentation

Try the live demos

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/crosshooks

React 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. with web-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