Add analytics to a form without rewriting any field component.
- Track focus, completion time, change counts, abandonment, errors, and successful completion
- Keep callbacks metadata-oriented so analytics stays safe and privacy-conscious
- Works best when paired with a stable getter for current values
- For most forms, passing
analyticsdirectly touseFormBridge(schema, { analytics })is the simplest path; the standalone hook is the lower-level escape hatch
| 1 | import { |
| 2 | field, |
| 3 | useFormBridge, |
| 4 | useFormBridgeAnalytics, |
| 5 | } from '@runilib/react-formbridge' |
| 6 | |
| 7 | const schema = { |
| 8 | email: field.email('Email').required(), |
| 9 | password: field.password('Password').required(), |
| 10 | } |
| 11 | |
| 12 | export function SignupWithAnalytics() { |
| 13 | const { Form, fields, state } = useFormBridge(schema) |
| 14 | |
| 15 | useFormBridgeAnalytics( |
| 16 | { |
| 17 | formId: 'signup', |
| 18 | exclude: ['password'], |
| 19 | handlers: { |
| 20 | onFieldComplete: (name, ms) => analytics.track('field_complete', { name, ms }), |
| 21 | onFormCompleted: (durationMs, submitCount, fieldCount) => |
| 22 | analytics.track('form_done', { durationMs, submitCount, fieldCount }), |
| 23 | onFormAbandoned: (pct, last, values) => |
| 24 | analytics.track('form_abandoned', { pct, last, values }), |
| 25 | }, |
| 26 | }, |
| 27 | () => state.values, |
| 28 | ) |
| 29 | |
| 30 | return ( |
| 31 | <Form onSubmit={(values) => api.signup(values)}> |
| 32 | <fields.email /> |
| 33 | <fields.password /> |
| 34 | <Form.Submit>Sign up</Form.Submit> |
| 35 | </Form> |
| 36 | ) |
| 37 | } |
Config & defaults
Complete AnalyticsOptions surface (the first argument of useFormBridgeAnalytics()):
| Method | Type | Description |
|---|---|---|
handlers | AnalyticsHandlers | Required - set of callbacks the tracker fires on every tracked event. Each handler is individually optional |
exclude? | string[] | Extra field names to exclude from every callback. Merged with the built-in deny-list ['password', 'confirm', 'cvv', 'pin', 'otp', 'ssn', 'secret']. Stripped from values / errors payloads passed to onFormAbandoned / onFormLevelError |
formId? | string | Optional identifier injected on the tracker instance. Pass-through only - the hook does not read it |
Passing undefined as the whole config disables analytics: the previous tracker (if any) is destroyed, the hook returns null, and no listeners stay attached. Handy for a feature-flagged rollout.
Minimal config
| 1 | const analyticsConfig: AnalyticsOptions = { |
| 2 | formId: 'signup', |
| 3 | exclude: ['taxId', 'dateOfBirth'], |
| 4 | handlers: { |
| 5 | onFieldComplete: (name, ms) => track('field_complete', { name, ms }), |
| 6 | onFormCompleted: (ms, submitCount) => track('form_done', { ms, submitCount }), |
| 7 | }, |
| 8 | } |
AnalyticsHandlers every callback is optional. The tracker skips excluded field names before invoking anything below.
Field lifecycle
| Method | Type | Description |
|---|---|---|
onFieldFocus? | (name) => void | Fires when a tracked field gains focus. Records the focus timestamp (used later for durationMs). Does not fire for excluded fields |
onFieldComplete? | (name, durationMs) => void | Fires on blur only when the blurred value is non-empty. durationMs = elapsed time between matching focus and this blur. Tracker then forgets the focus timestamp |
onFieldAbandoned? | (name, partialValue) => void | Fires on blur when the value is empty after focus. Mutually exclusive with onFieldComplete for a given focus/blur pair |
onFieldChange? | (name, changeCount) => void | Fires on every change. changeCount is a per-field counter to detect "hesitant" fields. No field value is ever passed (privacy) |
Field validation
| Method | Type | Description |
|---|---|---|
onFieldError? | (name, error) => void | Fires when a field enters an error state and the message differs from the previous one (dedup) |
onFieldErrorFixed? | (name) => void | Fires exactly once when a previously-errored field becomes valid again |
Form lifecycle
| Method | Type | Description |
|---|---|---|
onFormAbandoned? | (completedPercent, lastField, values) => void | Fires at most once per tracker when the user leaves while partially filled (0 < completedPercent < 100). values is a filtered snapshot. Triggered by pagehide / beforeunload / visibilitychange on web, AppState transitions on native |
onFormCompleted? | (durationMs, submitCount, fieldCount) => void | Fires once on successful submission. After this, the tracker is destroyed and no further events can be emitted |
onFormLevelError? | (errors, submitCount) => void | Fires on every failed submit attempt. errors has excluded fields stripped out |
Example - wiring every event to a single tracker
| 1 | const handlers: AnalyticsHandlers = { |
| 2 | onFieldFocus: (name) => track('field_focus', { name }), |
| 3 | onFieldComplete: (name, ms) => track('field_complete', { name, ms }), |
| 4 | onFieldAbandoned: (name) => track('field_abandoned', { name }), |
| 5 | onFieldChange: (name, count) => track('field_change', { name, count }), |
| 6 | onFieldError: (name, error) => track('field_error', { name, error }), |
| 7 | onFieldErrorFixed: (name) => track('field_error_fixed', { name }), |
| 8 | onFormAbandoned: (pct, lastField, values) => |
| 9 | track('form_abandoned', { pct, lastField, fieldCount: Object.keys(values).length }), |
| 10 | onFormCompleted: (ms, submitCount, fieldCount) => |
| 11 | track('form_done', { ms, submitCount, fieldCount }), |
| 12 | onFormLevelError: (errors, submitCount) => track('form_error', { errors, submitCount }), |
| 13 | } |
Returned tracker
useFormBridgeAnalytics(opts, getValues) returns a FormBridgeAnalyticsTracker | null:
- Returns
nullwhenoptsisundefined(analytics disabled). - Otherwise returns the live tracker instance so you can drive it imperatively from code that does not live inside
useFormBridge()- useful when wiring a third-party field component that needs to notify the tracker manually.
When you pass analytics directly to useFormBridge(schema, { analytics }), you never touch this instance - the core wires every handler for you. The surface below only matters if you call the standalone hook as an escape hatch.
Core imperative methods
| Method | Type | Description |
|---|---|---|
setValuesGetter | (getter: () => Record<string, unknown>) => void | Replaces the function the tracker calls to get latest values (used before onFormAbandoned). The hook re-binds this on every render to a ref-backed getter |
attachLifecycleTracking | () => void | Registers platform listeners (pagehide / beforeunload / visibilitychange on web, AppState.change on native). Idempotent |
destroy | () => void | Removes every listener and marks the tracker detached. Called automatically on unmount and implicitly inside onFormSubmitSuccess() |
Field events - call from a custom field integration
| Method | Type | Description |
|---|---|---|
onFieldFocus | (name) => void | Records the focus timestamp and fires handlers.onFieldFocus |
onFieldBlur | (name, value) => void | Computes duration and fires onFieldComplete or onFieldAbandoned depending on emptiness |
onFieldChange | (name) => void | Bumps per-field change counter and fires handlers.onFieldChange |
onFieldError | (name, error) => void | Fires handlers.onFieldError only when the message differs from the previous one (dedup) |
onFieldErrorFixed | (name) => void | Fires handlers.onFieldErrorFixed only if the field had a recorded error |
Form events
| Method | Type | Description |
|---|---|---|
onFormSubmitError | (errors, submitCount) => void | Filters excluded keys out of errors and fires handlers.onFormLevelError |
onFormSubmitSuccess | (submitCount, fieldCount) => void | Fires handlers.onFormCompleted with total duration since tracker start, then auto-destroys the tracker |
Completion helpers
| Method | Type | Description |
|---|---|---|
computeCompletion | (values) => number | Returns rounded integer percentage of non-empty, non-excluded fields. Suitable for progress bars |
reportAbandonmentIfNeeded | () => void | Same logic as lifecycle listeners: if partially filled and not yet reported, fires handlers.onFormAbandoned. Fires at most once per tracker |
Example - driving the tracker manually from a third-party field
| 1 | const analytics = useFormBridgeAnalytics( |
| 2 | { formId: 'signup', handlers: myHandlers }, |
| 3 | () => state.values, |
| 4 | ) |
| 5 | |
| 6 | return ( |
| 7 | <ThirdPartySignaturePad |
| 8 | onFocus={() => analytics?.onFieldFocus('signature')} |
| 9 | onBlur={(value) => analytics?.onFieldBlur('signature', value)} |
| 10 | onChange={() => analytics?.onFieldChange('signature')} |
| 11 | /> |
| 12 | ) |
Usage notes
- Pass a stable getter for current values, e.g. () => state.values
- No field values are sent in onFieldChange; keep events metadata-only
- Works on web (pagehide/beforeunload/visibilitychange) and React Native (AppState)