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

@clickclacks/react-native

v1.0.0

Published

React Native and Expo SDK for ClickClacks product analytics. Screens, events, identity and groups, with an offline queue and consent controls.

Readme

@clickclacks/react-native

Send screens, events, people and companies from a React Native or Expo app to ClickClacks product analytics.

  • Works offline: events wait in a small stored queue and are retried with backoff.
  • Consent controls built in: opt-out by default, or opt-in for the EU and UK.
  • Never throws into your app. Storage, validation and network problems are swallowed.
  • Zero runtime dependencies. No native code, so it works in Expo Go. ESM, CommonJS and TypeScript types.
  • React Native 0.74+ and Expo SDK 51+, iOS and Android.

Install

Expo

npx expo install @clickclacks/react-native @react-native-async-storage/async-storage

Bare React Native

npm install @clickclacks/react-native @react-native-async-storage/async-storage
cd ios && pod install

AsyncStorage is optional but recommended. It is where the SDK keeps its ids, the consent choice and the offline queue. Without it (and without a storage option) the SDK still works, but everything is forgotten when the app closes, so each launch looks like a new person. The SDK itself has no native code.

Quick start

Find the Source's public key in ClickClacks under Sources. It starts with pk_live_ and is safe to ship in an app. Never use a secret cks_live_ key here.

// analytics.ts
import { ClickClacks } from '@clickclacks/react-native'

export const clickclacks = new ClickClacks({ key: 'pk_live_YOUR_SOURCE_KEY' })
import { clickclacks } from './analytics'

clickclacks.screen('Home')
clickclacks.track('Export finished', { rows: 12, format: 'csv' })

// After sign-in, with your own opaque user id:
clickclacks.identify('user_8412')
clickclacks.group('company', 'cmp_311', { plan: 'pro', seats: 41 })

// On sign-out:
clickclacks.reset()

Create one client and share it. Every method returns at once and works in the background; you don't need to await anything. Events are sent in batches of up to 25: when 25 are queued, every 5 seconds, and when the app leaves the foreground.

To also send the app's version and the device model, see Device and app details.

React Navigation

createNavigationTracker reports the current route as a screen. It doesn't import React Navigation; it only reads getCurrentRoute() from your navigation ref.

import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native'
import { createNavigationTracker } from '@clickclacks/react-native'
import { clickclacks } from './analytics'

export function App() {
  const navigationRef = useNavigationContainerRef()
  const screens = createNavigationTracker(clickclacks, navigationRef)
  return (
    <NavigationContainer ref={navigationRef} onReady={screens.onReady} onStateChange={screens.onStateChange}>
      {/* … */}
    </NavigationContainer>
  )
}

onReady reports the first screen (deep links included) and onStateChange every one after. State changes that keep the same route send nothing. Route params are never sent.

To rename or skip routes, pass screenName:

const screens = createNavigationTracker(clickclacks, navigationRef, {
  screenName: (route) => (route.name === 'Passcode' ? null : route.name),
})

With Expo Router, report the pathname instead:

import { usePathname } from 'expo-router'
import { useEffect } from 'react'

export default function RootLayout() {
  const pathname = usePathname()
  useEffect(() => {
    clickclacks.screen(pathname)
  }, [pathname])
  // …
}

usePathname() returns the real path (/orders/8412), not the route pattern (/orders/[id]). If your paths hold ids, build a name from useSegments() instead so screens group together and no id is sent.

API

new ClickClacks(options)

| Option | Default | | |---|---|---| | key | required | The Source's public key, pk_live_…. With anything else the client collects nothing. | | host | https://app.clickclacks.io | Pass your custom tracker domain if you have one, for example https://t.example.com. Events then go to https://t.example.com/e, the only path a tracker domain accepts; the default host takes /api/ingest. If your project has a custom tracker domain, always set this: the default host stops accepting that project's events 30 days after the domain goes live. | | consent | 'opt-out' | 'opt-out' collects until optOut(). 'opt-in' collects nothing until optIn(). See Consent. | | storage | AsyncStorage | Any { getItem, setItem, removeItem }. See Storage. | | appVersion | none | Sent as $app_version. | | appBuild | none | Sent as $app_build. | | deviceModel | none | Sent as $device_model. | | deviceType | guessed | 'phone' or 'tablet'. Guessed from Platform.isPad on iOS; 'phone' on Android. | | flushAt | 25 | Queued events that trigger a send at once, 1–25. | | flushIntervalMs | 5000 | Milliseconds between automatic sends. 0 turns the timer off. | | debug | development only | true logs every problem with console.warn; false logs nothing. By default problems are logged in development builds (__DEV__) and not in release builds. | | fetch | global fetch | Replace it for proxies or tests. |

The constructor never throws. With a missing or secret key it warns and every method does nothing.

track(name, properties?)

Records a custom event. name is 1–128 characters and must not start with $. properties is a plain object of JSON values.

clickclacks.track('Subscription started', { plan: 'pro', seats: 3 })

screen(name, properties?)

Records a screen view. It is stored as a $pageview with path: '/' + name and title: name, so screens appear wherever pages do in ClickClacks. A leading slash you supply is kept (screen('/orders/details')), and runs of whitespace become one space. name is 1–128 characters. Reporting the same screen twice in a row sends it once.

identify(distinctId)

Links this device's events to your user. It sends one $identify event and stamps $distinct_id on every later event until reset(). Calling it again with the same id sends nothing. distinctId is trimmed and at most 512 characters.

When a different user signs in on the same device, call reset() first. Otherwise both users are linked to the same anonymous person.

Use an opaque internal id (user_8412), never an email address, phone number or name.

group(type, id, traits?)

Says which company, workspace or team the person is acting for. See Groups.

reset()

Call it on sign-out. It forgets the identified user and the groups and starts a new anonymous person and session, so the next person to use the device isn't mixed up with the last one. Events already queued are still sent. It doesn't change consent.

optIn(), optOut(), hasOptedOut()

See Consent. optIn() and optOut() resolve true when the choice was stored, false when it only holds until the app closes. hasOptedOut() resolves true whenever collection is off.

flush()

Sends what is queued now. It resolves when the attempt is over and never rejects. While the SDK is backing off after a failure, flush() waits for the backoff like every other send.

shutdown()

Flushes, stops the timer and the app-state listener, and ignores every later call. You rarely need it: it's for tests and for replacing the client.

Every method returns a promise that never rejects. track, screen, identify, group and reset resolve once the change is stored on the device, which is useful in tests.

Device and app details

Every event carries $lib (react-native), $lib_version, $os_name (iOS or Android), $os_version and $device_type (phone or tablet).

The SDK depends on no Expo or device-info package, so the app's version and the device model come from you. One line each:

// Expo
import * as Application from 'expo-application'
import * as Device from 'expo-device'

export const clickclacks = new ClickClacks({
  key: 'pk_live_YOUR_SOURCE_KEY',
  appVersion: Application.nativeApplicationVersion,
  appBuild: Application.nativeBuildVersion,
  deviceModel: Device.modelName,
  deviceType: Device.deviceType === Device.DeviceType.TABLET ? 'tablet' : 'phone',
})
// react-native-device-info
import DeviceInfo from 'react-native-device-info'

export const clickclacks = new ClickClacks({
  key: 'pk_live_YOUR_SOURCE_KEY',
  appVersion: DeviceInfo.getVersion(),
  appBuild: DeviceInfo.getBuildNumber(),
  deviceModel: DeviceInfo.getModel(),
  deviceType: DeviceInfo.isTablet() ? 'tablet' : 'phone',
})

Android tablets are reported as phone unless you pass deviceType. Without either package, a width check is close enough:

import { Dimensions } from 'react-native'

const { width, height } = Dimensions.get('window')
const deviceType = Math.min(width, height) >= 600 ? 'tablet' : 'phone'

Consent

The SDK supports two modes. You decide which one fits your app, your users and the law that applies to you.

Opt-out mode is not valid prior consent in the EU or the UK. Their rules generally require consent before analytics like this starts, and that covers identifiers stored on a device, not only cookies. If you have users there, use opt-in mode and call optIn() only after the person agrees.

Opt-out (default)

Collection starts with the first call. Connect your app's analytics switch:

clickclacks.optOut() // stop, forget everything, and remember the refusal
clickclacks.optIn()  // start again after the person changes their mind

Opt-in

export const clickclacks = new ClickClacks({ key: 'pk_live_YOUR_SOURCE_KEY', consent: 'opt-in' })

// When the person agrees in your consent screen:
clickclacks.optIn()

// When they refuse or withdraw:
clickclacks.optOut()

Before optIn() nothing is collected or queued, no id is created and nothing is written to storage. The SDK only reads its own consent record. The grant is remembered, so you don't need to call optIn() again on later launches. A stored id is never treated as consent.

What optOut() does

It drops the queue, the person and session ids, the identified user and the groups, cancels pending retries and stores the refusal. The refusal is written first and the rest is cleared after, so a crash halfway leaves a refusal, never data without one. A request that had already left the device can't be recalled.

optOut() stops future collection. It doesn't delete what ClickClacks already holds; handle erasure requests in ClickClacks.

If the refusal can't be written to storage, optOut() resolves false. Collection stays off until the app closes, but in opt-out mode it would start again on the next launch, so show the person an error and let them try again.

If the consent record can't be read at launch, nothing is collected until your app calls optIn().

Groups

clickclacks.group('company', 'cmp_311')                           // join
clickclacks.group('company', 'cmp_311', { plan: 'pro', seats: 41 }) // join and record traits
clickclacks.group('company', null)                                // leave
  • type is 1–64 characters of a-z, 0-9 and _, for example company. A person can be in at most 5 types at once; a sixth is ignored.
  • id is your id for the group, 1–255 characters. A number is sent as a string. Use an opaque id (cmp_311), not a domain name or an email address.
  • The groups are remembered on the device and stamped as $groups on every later event, until the person leaves the group, reset() or optOut().
  • Traits are sent as one $group_identify event. The newest traits replace the group's whole set, so pass every trait each time. Calling group() with the same type, id and traits again sends nothing until 7 days have passed, so it is safe to call on every launch.
  • Traits that look personal (an email address, a phone number, or keys such as email, phone, address, password) are dropped by ClickClacks unless the Source allows them.
  • Invalid input is ignored without a warning.

Storage

By default the SDK uses @react-native-async-storage/async-storage when it's installed. To use something else, pass any object with getItem, setItem and removeItem; they may return promises or plain values. For MMKV (shown for react-native-mmkv 2 and 3):

import { MMKV } from 'react-native-mmkv'

const mmkv = new MMKV({ id: 'clickclacks' })

export const clickclacks = new ClickClacks({
  key: 'pk_live_YOUR_SOURCE_KEY',
  storage: {
    getItem: (key) => mmkv.getString(key) ?? null,
    setItem: (key, value) => mmkv.set(key, value),
    removeItem: (key) => mmkv.delete(key),
  },
})

The SDK writes three keys, named after your Source key so two clients never collide: clickclacks:<key>:consent, clickclacks:<key>:state and clickclacks:<key>:queue.

Privacy

  • The SDK sends only what you pass to it, plus the device details listed above. It never reads the advertising id (IDFA or GAID), contacts, location, or anything else on the device, and it captures no taps or text by itself.
  • The person id (per_…) and session id (ses_…) are random labels made on the device. They aren't derived from the device or the user. A session ends after 30 minutes without an event.
  • Use an opaque internal id in identify(). A hashed email is still personal data: anyone who knows the address can compute the same hash.
  • Don't put secrets, tokens, message text, precise locations or health data in properties. Screen names are sent as written, so don't build them from user content.
  • Your App Store and Google Play privacy answers must describe this collection. With identify(), the data is linked to the user.

Limits

| | | |---|---| | Event and screen names | 1–128 characters. Event names must not start with $. | | Properties | 2,048 bytes of UTF-8 JSON per event, including the SDK's own keys. | | identify id | 1–512 characters. | | Group type | 1–64 characters of a-z, 0-9, _; at most 5 types. | | Group id | 1–255 characters. | | Offline queue | 100 events. When it's full, the oldest are dropped. | | Event age | 10 minutes. ClickClacks would store an older event at the time it arrives, not when it happened, so the SDK drops it instead. | | Request | Up to 25 events; abandoned after 15 seconds. |

When an event's properties are over 2,048 bytes, the event is still sent, without your properties: it keeps the SDK's own keys ($distinct_id, $groups, the device details, and a screen's path and title). Group traits that are too large aren't sent at all, because sending them empty would erase the group's traits.

The 10-minute limit means this SDK isn't for long offline use: events tracked while the device has no connection for more than 10 minutes are lost.

How delivery works

  • Batching: up to 25 events per request, sent when flushAt events are queued, every flushIntervalMs, and when the app becomes inactive or goes to the background.
  • Success: only HTTP 202 counts.
  • Retries: network errors, timeouts, 408, 429 and 5xx keep the events and retry after 2 s, 4 s, 8 s and so on, up to 60 s between attempts.
  • Refusals: any other answer drops that batch, so a bad batch can't block newer events. After 5 refused requests in a row the SDK stops sending until the app restarts. That almost always means a wrong key or host.
  • No double counting: every event gets an id (evt_ plus 32 hex characters) when it is queued, stored with it. A retry, also after a relaunch, sends the same ids, and ClickClacks drops repeats.
  • Persistence: the queue is stored after every change and restored on the next launch.

Troubleshooting

Turn on debug: true to see every problem in the console.

  • Nothing arrives. Check that the key is the Source's pk_live_… key, and the host if you set one. In opt-in mode, check that optIn() was called. await clickclacks.hasOptedOut() tells you whether collection is off.
  • Events stopped arriving and the project has a custom tracker domain. The default host answers 410 for a project 30 days after its custom domain goes live. Set host to the custom domain (the bare domain, for example https://t.example.com; the SDK posts to its /e path) and ship an update.
  • "stopped sending after 5 refused requests". ClickClacks refused five requests in a row. Check the key and the host, then restart the app.
  • Every launch is a new person. AsyncStorage isn't installed, or wasn't linked (bare React Native: run pod install and rebuild). Or pass a storage adapter.
  • Events are missing after the device was offline. The SDK drops events older than 10 minutes, because ClickClacks would store them at the time they arrive instead of when they happened.
  • Events show at the wrong time. The device clock is more than 10 minutes wrong. ClickClacks then stores each event at the time it arrives.
  • 404 from a custom tracker domain. A tracker domain only accepts POST /e. Pass the bare domain as host (https://t.example.com) and the SDK adds /e. A host that already ends in /e or /api/ingest is used as written.
  • Fewer screen views than expected. The same screen twice in a row counts once. Going Home → Settings → Home is three screen views.
  • Android tablets show as phones. Pass deviceType.
  • Events tracked right before the app is killed are missing. Queued events are stored and sent on the next launch, but only if that's within 10 minutes.
  • Jest. Mock AsyncStorage with its official Jest mock, or pass an in-memory storage.

Native Swift and Kotlin apps

There's no native SDK yet. A fully native app can send the same events over HTTP with the Source's public key:

POST https://app.clickclacks.io/api/ingest
Content-Type: application/json

{
  "key": "pk_live_YOUR_SOURCE_KEY",
  "events": [
    {
      "id": "evt_0123456789abcdef0123456789abcdef",
      "name": "Export finished",
      "ts": "2026-10-01T09:30:00.000Z",
      "person_id": "per_0123456789abcdef0123456789abcdef",
      "session_id": "ses_0123456789abcdef0123456789abcdef",
      "properties": { "rows": 12, "$os_name": "iOS", "$os_version": "18.1", "$device_type": "phone" }
    }
  ]
}

If the project has a custom tracker domain, post to https://<your tracker domain>/e instead, with the same body.

Don't send an Origin header. Only 202 is success. Send at most 25 events per request, keep properties under 2,048 bytes, and keep ts within 10 minutes of now (an older event is accepted but stored at the time it arrives, so drop it instead). Generate person_id once and store it; start a new session_id after 30 minutes without an event. Give every event an id (evt_ plus 8–80 characters of A-Z, a-z, 0-9, _, -) when you create it, and send the same id when you retry: ClickClacks drops repeats, so a retry never double counts. The full contract is in the ClickClacks docs.

Development

npm install
npm run lint && npm run typecheck && npm test
npm run build && npm run test:package   # packs the tarball and checks both entry points and the types
npm run test:metro                       # bundles the tarball with Metro and runs the bundle

The tests run in Node against a stand-in for react-native (test/stubs/react-native.ts), an in-memory storage adapter, a fake fetch and fake timers. npm run typecheck compiles the source against the real react-native types.

Licence

MIT