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 firstload()call and returns it afterwards.load()does no collection work and keeps no state betweencollect()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-noncereturns{ nonce, timestamp }, which goes straight intocollect({ binding }). The result’sbindingthen 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. Thetryblock covers the twofetchcalls;finallyre-enables the button. - Send it unchanged.
deviceis 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 arenull. The Vite dev server onlocalhostworks. - Disable the button while collecting. A double click would otherwise fetch two nonces and send two results; the
busystate prevents that. - Preloading. If you want the agent ready before the first submit, call
getAgent()from auseEffectin 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.