Skip to content

React Hook API

The @sentinel-password/react package provides a React hook with built-in state management and debouncing on top of @sentinel-password/core.

Installation

bash
npm install @sentinel-password/react

@sentinel-password/core is pulled in transitively as a regular dependency of @sentinel-password/react — you don't need to install it directly.

Peer Dependencies: React 18 or 19 (^18.0.0 || ^19.0.0)

Hooks

usePasswordValidator()

Manages a password string, runs validation against @sentinel-password/core, and exposes the latest result.

Signature:

typescript
function usePasswordValidator(
  options?: UsePasswordValidatorOptions
): UsePasswordValidatorReturn

Parameters:

typescript
interface UsePasswordValidatorOptions extends ValidatorOptions {
  debounceMs?: number        // Default: 300. Set to 0 to disable.
  initialPassword?: string   // Default: ''. Seed value for the hook's password state.
  validateOnMount?: boolean  // Default: false. Validates `initialPassword` once on mount.
  validateOnChange?: boolean // Default: false
}
OptionTypeDefaultDescription
debounceMsnumber300Delay in ms after setPassword before validating. 0 disables debouncing (paired with validateOnChange: true for instant validation).
initialPasswordstring''Seed value for the hook's password state. Pair with validateOnMount: true to validate a pre-filled value (e.g. edit-profile flows) on first render. The input stays fully controlled by setPassword afterwards.
validateOnMountbooleanfalseValidate initialPassword once on mount. Skips empty values, so it's a no-op when initialPassword is empty or omitted.
validateOnChangebooleanfalseOnly takes effect when debounceMs === 0. See the behavior matrix below.
...all ValidatorOptionsAll flat options from @sentinel-password/core (minLength, requireUppercase, personalInfo, etc.).

When validation runs

debounceMs and validateOnChange interact — the table below covers every combination:

debounceMsvalidateOnChangeBehavior on setPassword
> 0 (default 300)any valueDebounced validation runs on every change. validateOnChange is ignored.
0trueSynchronous validation on every change.
0falseManual mode — no automatic validation. Call validate() yourself.

Returns:

typescript
interface UsePasswordValidatorReturn {
  password: string
  setPassword: (password: string) => void
  result: ValidationResult | undefined
  isValidating: boolean
  validate: () => void
  reset: () => void
}
PropertyTypeDescription
passwordstringCurrent password value held by the hook
setPassword(password: string) => voidUpdate the password. Whether validation also fires depends on debounceMs / validateOnChange — see the behavior matrix. In manual mode (debounceMs: 0 + validateOnChange: false), setPassword only updates state; call validate() to evaluate. Note: takes a string, not an event.
resultValidationResult | undefinedLatest validation result, or undefined until first validation
isValidatingbooleantrue while a debounced validation is pending
validate() => voidManually trigger validation against the current password
reset() => voidClear the password and validation state

ValidationResult is the same shape returned by validatePassword: { valid, score, strength, feedback: { warning?, suggestions }, checks }.

Basic Usage

Simple Form

tsx
import { usePasswordValidator } from '@sentinel-password/react'

function PasswordForm() {
  const { password, setPassword, result } = usePasswordValidator({
    minLength: 8,
    requireUppercase: true,
    requireDigit: true,
  })

  return (
    <div>
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        aria-invalid={result && !result.valid ? true : undefined}
      />

      {result && !result.valid && (
        <ul role="alert">
          {result.feedback.suggestions.map((s, i) => (
            <li key={i}>{s}</li>
          ))}
        </ul>
      )}
    </div>
  )
}

Signup Form With Strength Indicator

tsx
import { useState } from 'react'
import { usePasswordValidator } from '@sentinel-password/react'

function SignupForm() {
  const [submitted, setSubmitted] = useState(false)

  const { password, setPassword, result, reset } = usePasswordValidator({
    minLength: 12,
    requireUppercase: true,
    requireLowercase: true,
    requireDigit: true,
    requireSymbol: true,
    debounceMs: 500,
  })

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    if (!result?.valid) return
    setSubmitted(true)
    reset()
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="password">Create Password</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        aria-invalid={result && !result.valid ? true : undefined}
        aria-describedby="password-feedback"
      />

      <div id="password-feedback" role="alert" aria-live="polite">
        {result?.feedback.warning && <p>{result.feedback.warning}</p>}
      </div>

      {result && <p>Strength: <strong>{result.strength}</strong></p>}

      <button type="submit" disabled={!result?.valid}>
        Create Account
      </button>

      {submitted && <p>Account created!</p>}
    </form>
  )
}

Advanced Usage

Custom Debounce

tsx
// Default 300ms debounce
usePasswordValidator({ minLength: 8 })

// Slow debounce for expensive backend checks
usePasswordValidator({ minLength: 8, debounceMs: 1000 })

// No debounce — validate on every keystroke
usePasswordValidator({
  minLength: 8,
  debounceMs: 0,
  validateOnChange: true,
})

Manual Validation

If you set debounceMs: 0 without validateOnChange, automatic validation is disabled — call validate() yourself, e.g. on submit.

tsx
const { password, setPassword, result, validate } = usePasswordValidator({
  minLength: 8,
  debounceMs: 0,
})

return (
  <form
    onSubmit={(e) => {
      e.preventDefault()
      validate()
    }}
  >
    <input value={password} onChange={(e) => setPassword(e.target.value)} />
    <button type="submit">Validate</button>
  </form>
)

Stale-state caveat for validate()

validate() reads password through its useCallback closure. After setPassword(value) runs synchronously, React batches the state update — the next render rebuilds validate with the new password.

Works — any event handler that fires after React has committed a re-render:

tsx
// Submit, blur, button click, etc. — by the time these fire, password
// is whatever the user most recently set.
<button onClick={validate}>Validate now</button>

Doesn't work — back-to-back calls in the same synchronous tick:

tsx
setPassword('new-value')
validate() // ← validates the OLD password; the closure here was built
           //   in the previous render with the previous password.

If you need to validate a specific value right now (without waiting for the hook to re-render), call validatePassword from core directly:

tsx
import { validatePassword } from '@sentinel-password/core'

const result = validatePassword('new-value', { minLength: 8 })
// result is fresh — no closure, no state, no waiting for a re-render.

Programmatic Updates

tsx
function PasswordGenerator() {
  const { password, setPassword, result } = usePasswordValidator({
    minLength: 12,
    requireUppercase: true,
    requireDigit: true,
    requireSymbol: true,
  })

  const generate = () => {
    setPassword(crypto.randomUUID().slice(0, 12) + 'A1!')
  }

  return (
    <div>
      <button onClick={generate}>Generate</button>
      <p>Generated: {password}</p>
      <p>Strength: {result?.strength}</p>
    </div>
  )
}

Reset

reset() clears both the password and the cached result.

tsx
const { password, setPassword, result, reset } = usePasswordValidator()

// later...
reset() // password becomes '', result becomes undefined

TypeScript

Full type inference is automatic. You can also import the option and return types directly:

typescript
import { usePasswordValidator } from '@sentinel-password/react'
import type {
  UsePasswordValidatorOptions,
  UsePasswordValidatorReturn,
} from '@sentinel-password/react'

const options: UsePasswordValidatorOptions = {
  minLength: 8,
  requireUppercase: true,
  debounceMs: 300,
}

Performance Tips

Pick a Debounce That Matches Your Validators

  • 0ms (with validateOnChange: true): fine for the default policy — validation takes microseconds.
  • 300ms (default): a reasonable balance for typing latency.
  • 500–1000ms: useful only if you chain expensive external checks (e.g. server-side breach lookup) on top of validation.

Callback Identity Is Not Stable (Known Limitation)

setPassword and validate from usePasswordValidator are not reference-stable across renders today. Internally the hook destructures its options with { ...validatorOptions } = options on every render, producing a fresh object identity that lands in the useCallback dependency arrays for setPassword ([debounceMs, validateOnChange, validatorOptions]) and validate ([password, validatorOptions]). So even if you wrap your options in useMemo, the derived validatorOptions still changes identity and the callbacks get re-created.

reset is stable — its useCallback dependency array is empty (it touches only refs and state setters, both exempt from React's deps), so its identity is fixed for the component's lifetime.

Practical implications:

  • Don't bother memoizing the options object with useMemo expecting it to stabilize the returned callbacks. It won't — user-side memoization buys nothing here.
  • Don't rely on setPassword/validate identity as useEffect or useMemo dependencies if you're trying to "only run once." They'll change on every render.
  • reset is safe to use directly as a dep, pass to a memoized child, or store in a ref-less manner.

If you do need a stable handler to pass to a deep child:

tsx
import { useCallback, useRef } from 'react'
import { usePasswordValidator } from '@sentinel-password/react'

function MyForm() {
  const { password, setPassword, result } = usePasswordValidator({ minLength: 12 })

  // Capture the latest setPassword in a ref, then wrap with a stable
  // useCallback that reads through the ref.
  const setPasswordRef = useRef(setPassword)
  setPasswordRef.current = setPassword
  const stableSetPassword = useCallback((value: string) => setPasswordRef.current(value), [])

  // Pass `stableSetPassword` to memoized children; it never changes identity.
  // ...
}

This is a hook-internal limitation, not a contract — a future release may make setPassword and validate reference-stable too. For now, write your code as if those two change every render; reset is already stable today.

See Also

Released under the MIT License.