Guides · WhiskerJS 0.1.0

React

A login form component that creates one WhiskerJS agent on first use and collects when the form is submitted.

In plain words

The component below is an ordinary sign-in form. When it is submitted it asks your server for a one-time code, lets WhiskerJS read the browser, and sends the result along with the email and password. WhiskerJS is loaded once and only in the browser, so nothing changes about how your app renders.

Install: build from source

WhiskerJS is not on npm yet; the npm package is coming. Build it and install the tarball into your React project:

cd whiskerjs
pnpm install && pnpm build && pnpm pack    # writes whiskerjs-0.1.0.tgz
cd ../my-react-app
npm install ../whiskerjs/whiskerjs-0.1.0.tgz

The package ships its own type definitions; no @types package is needed.

The component

import { useState, type FormEvent } from 'react';
import { load, type Agent } from 'whiskerjs';

// One agent per page, created on first use. load() is cheap and keeps no state.
let agent: Promise<Agent> | undefined;
const getAgent = () => (agent ??= load());

export function LoginForm() {
  const [status, setStatus] = useState('');
  const [busy, setBusy] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);
    setBusy(true);
    try {
      // A fresh nonce from your server, then collect. collect() never throws.
      const binding = await (await fetch('/api/whisker-nonce')).json();
      const device = await (await getAgent()).collect({ timeoutMs: 3000, binding });
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ email: form.get('email'), password: form.get('password'), device }),
      });
      setStatus(response.ok ? 'Signed in' : `Refused (${response.status})`);
    } catch {
      setStatus('Network error, please try again');
    } finally {
      setBusy(false);
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <label>Email <input name="email" type="email" required /></label>
      <label>Password <input name="password" type="password" required /></label>
      <button type="submit" disabled={busy}>Sign in</button>
      <output id="status">{status}</output>
    </form>
  );
}

And where it is mounted, here in a Vite app:

import { createRoot } from 'react-dom/client';
import { LoginForm } from './LoginForm';

createRoot(document.getElementById('root')!).render(<LoginForm />);

How it works

  • One agent, created on first use. getAgent() keeps the promise from the first load() call and returns it afterwards. load() does no collection work and keeps no state between collect() calls, so one agent per page is enough. It lives outside the component so re-renders do not create new agents.
  • Collect in the handler, not in render. collect() reads the browser and adds hidden elements to the page for a moment, so it belongs in an event handler (or an effect), never in the render function.
  • The binding. /api/whisker-nonce returns { nonce, timestamp }, which goes straight into collect({ binding }). The result’s binding then carries both and a digest over the whole result. The server checks it; see Verify in Node or the Go, PHP and Python guides.
  • Errors. collect() never throws or rejects. The try block covers the two fetch calls; finally re-enables the button.
  • Send it unchanged. device is the whole result object. Do not pick fields out of it before sending if the server verifies the binding: the digest covers every field.

Things to know

  • Secure context. On a plain http page other than localhost, Web Crypto is missing and the ids are null. The Vite dev server on localhost works.
  • Disable the button while collecting. A double click would otherwise fetch two nonces and send two results; the busy state prevents that.
  • Preloading. If you want the agent ready before the first submit, call getAgent() from a useEffect in the component. It stays cheap either way.
  • What you do with the answer. The server’s verdict comes from your own rules. See Integration for what to check beyond the binding.

Tested

Tested with WhiskerJS 0.1.0 (signalsVersion 5, idVersion 2), React 19.3.1, Vite 8.3.5 and TypeScript 5.9.3 (strict) on 2026-10-26: built with tsc --noEmit && vite build, served by the Node server and submitted in headless Chromium 140 (Playwright 1.55.0). The server accepted the binding and refused the same result sent a second time.

Questions and answers

Should I call collect() in useEffect on page load?

Usually not. Collect on the action you protect, such as the submit of a login or checkout form. The nonce then belongs to that action and the result is fresh when the server checks it.

Does React StrictMode cause two collections?

Not with this component. StrictMode runs effects and render functions twice in development, but collect() is only called from the submit handler, which runs once per submit.

Does this work with server-side rendering?

Yes. load() and collect() are only called inside the submit handler, which never runs on the server. For Next.js see the Next.js guide.

Where do the types come from?

The tarball contains .d.ts files, so Agent, CollectResult and the option types are available from 'whiskerjs' with no extra package.