Tutorial React React Hook Form email validation form integration API TypeScript

Integrate Syvel with React Hook Form in 10 Minutes

Step-by-step guide to validate emails in real time in your React forms without UX friction. Full TypeScript integration with fail-open pattern and typo correction.

By Syvel Team · · 7 min read

What You’ll Build

At the end of this guide, your React form will:

  • Validate emails in real time via the Syvel API
  • Display a visual indicator (loading → ✓ valid / ✗ problem)
  • Only block submission for clearly problematic addresses (is_risky === true)
  • Show an optional warning for suspicious addresses (score 50–64)
  • Suggest a typo correction if did_you_mean is returned (gmial.comgmail.com)
  • Follow UX best practices: no intrusive validation, debounce, graceful error handling

All in under 60 lines of code.


Prerequisites

  • React project with React Hook Form (npm install react-hook-form)
  • A Syvel API key (free, create an account →)
  • Node 18+

API Response: Fields Used

The GET /v1/check/{email} endpoint returns:

interface SyvelResult {
risk_score: number; // 0-100
is_risky: boolean; // true if risk_score >= project threshold (default: 65)
reason: 'safe' | 'disposable' | 'undeliverable' | 'role_account';
did_you_mean: string | null; // e.g. "gmail.com" for "gmial.com"
deliverability_score: number; // 0-100
is_free_provider: boolean;
is_corporate_email: boolean;
is_alias_email: boolean;
mx_provider_label: string | null;
}

Step 1: Create the Validation Hook

Create a file src/hooks/useEmailValidation.ts:

import { useState, useCallback } from 'react';
interface SyvelResult {
risk_score: number;
is_risky: boolean;
reason: 'safe' | 'disposable' | 'undeliverable' | 'role_account';
did_you_mean: string | null;
deliverability_score: number;
}
interface ValidationState {
status: 'idle' | 'loading' | 'valid' | 'warning' | 'error';
message?: string;
didYouMean?: string;
score?: number;
}
export function useEmailValidation(apiKey: string) {
const [state, setState] = useState<ValidationState>({ status: 'idle' });
const validate = useCallback(async (email: string): Promise<string | true> => {
if (!email || email.length < 5) return true; // Let it pass, format validation handles it
setState({ status: 'loading' });
try {
const res = await fetch(
`https://api.syvel.io/v1/check/${encodeURIComponent(email)}`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
if (!res.ok) {
// On API error, don't block the user (fail open)
setState({ status: 'idle' });
return true;
}
const data: SyvelResult = await res.json();
// Typo detected — warn without blocking
if (data.did_you_mean) {
setState({
status: 'warning',
message: `Did you mean ${data.did_you_mean}?`,
didYouMean: data.did_you_mean,
score: data.risk_score,
});
return true;
}
if (data.is_risky) {
let message = 'This email address is not accepted.';
if (data.reason === 'undeliverable') {
message = 'This domain cannot receive emails.';
} else if (data.reason === 'role_account') {
message = 'Please use a personal email address.';
}
setState({ status: 'error', message, score: data.risk_score });
return message;
}
// Moderate score (non-blocking): soft warning
if (data.risk_score >= 50) {
setState({
status: 'warning',
message: 'This address appears to be temporary. Please use your professional email.',
score: data.risk_score,
});
return true;
}
setState({ status: 'valid', score: data.risk_score });
return true;
} catch {
// Network error: fail open (never block a legitimate user)
setState({ status: 'idle' });
return true;
}
}, [apiKey]);
return { state, validate };
}

Key points:

  • Fail open: if the Syvel API is unreachable, let the email pass. Never block a legitimate user due to an external server error.
  • is_risky: the recommended blocking field. The threshold is configurable in your dashboard.
  • did_you_mean: offer the correction in real time rather than blocking.
  • Score 50–64: visible warning but submission allowed. Useful for B2C forms.

Step 2: Create the Indicator Component

Create src/components/EmailInput.tsx:

import React from 'react';
import { UseFormRegister, FieldErrors } from 'react-hook-form';
interface ValidationState {
status: 'idle' | 'loading' | 'valid' | 'warning' | 'error';
message?: string;
didYouMean?: string;
}
interface Props {
register: UseFormRegister<any>;
errors: FieldErrors;
validationState: ValidationState;
onDidYouMeanClick?: (suggestion: string) => void;
name?: string;
}
function StatusIcon({ status }: { status: ValidationState['status'] }) {
switch (status) {
case 'loading':
return <span className="email-icon loading" aria-hidden></span>;
case 'valid':
return <span className="email-icon valid" aria-hidden></span>;
case 'warning':
return <span className="email-icon warning" aria-hidden></span>;
case 'error':
return <span className="email-icon error" aria-hidden></span>;
default:
return null;
}
}
export function EmailInput({ register, errors, validationState, onDidYouMeanClick, name = 'email' }: Props) {
return (
<div className="field-wrapper">
<label htmlFor={name}>Email address</label>
<div className="input-wrapper">
<input
id={name}
type="email"
autoComplete="email"
aria-invalid={!!errors[name]}
aria-describedby={`${name}-hint`}
className={`email-input status-${validationState.status}`}
{...register(name)}
/>
<StatusIcon status={validationState.status} />
</div>
{/* RHF error message (invalid format) */}
{errors[name] && (
<p id={`${name}-hint`} role="alert" className="field-error">
{errors[name]?.message as string}
</p>
)}
{/* Syvel warning (non-blocking) */}
{validationState.status === 'warning' && !errors[name] && (
<p id={`${name}-hint`} role="status" className="field-warning">
{validationState.message}
{validationState.didYouMean && onDidYouMeanClick && (
<button
type="button"
className="did-you-mean-btn"
onClick={() => onDidYouMeanClick(validationState.didYouMean!)}
>
Use {validationState.didYouMean}
</button>
)}
</p>
)}
</div>
);
}

Step 3: Assemble the Form

import React from 'react';
import { useForm } from 'react-hook-form';
import { useEmailValidation } from '../hooks/useEmailValidation';
import { EmailInput } from '../components/EmailInput';
const API_KEY = import.meta.env.VITE_SYVEL_API_KEY;
interface FormData {
email: string;
name: string;
}
export function SignupForm() {
const { state: emailState, validate } = useEmailValidation(API_KEY);
const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
} = useForm<FormData>({
mode: 'onBlur', // Validate on focus loss, not on every keystroke
});
const handleDidYouMean = (suggestion: string) => {
const currentEmail = (document.getElementById('email') as HTMLInputElement)?.value;
if (currentEmail && currentEmail.includes('@')) {
const local = currentEmail.split('@')[0];
setValue('email', `${local}@${suggestion}`, { shouldValidate: true });
}
};
const onSubmit = async (data: FormData) => {
console.log('Form submitted:', data);
// Your signup logic here
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div className="field-wrapper">
<label htmlFor="name">Name</label>
<input
id="name"
type="text"
autoComplete="name"
{...register('name', { required: 'Name is required.' })}
/>
{errors.name && <p className="field-error">{errors.name.message}</p>}
</div>
<EmailInput
register={register}
errors={errors}
validationState={emailState}
onDidYouMeanClick={handleDidYouMean}
name="email"
/>
<button
type="submit"
disabled={isSubmitting || emailState.status === 'loading'}
>
{isSubmitting ? 'Creating account...' : 'Create my account'}
</button>
</form>
);
}

To wire Syvel validation into the React Hook Form cycle:

// In the EmailInput component, modify register('email') like this:
{...register(name, {
required: 'Email is required.',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Invalid email format.',
},
validate: async (value) => await validate(value),
})}

Step 4: Add Debounce

If you validate in onChange mode rather than onBlur, add a debounce to avoid an API call on every keystroke:

import { useMemo } from 'react';
import { debounce } from 'lodash-es'; // or your own implementation
// In useEmailValidation, wrap validate with debounce:
const debouncedValidate = useMemo(
() => debounce(validate, 500),
[validate]
);

In onBlur mode (recommended), debounce isn’t necessary — validation only triggers when the user leaves the field.


Environment Variables

In your .env.local:

Terminal window
VITE_SYVEL_API_KEY=sv_your_key_here

Never commit your API key. Add .env.local to your .gitignore. In production, inject the variable via your CI/CD pipeline.

For applications where the key would be exposed client-side, prefer routing the API call through your own backend (BFF) that holds the key server-side.


Final Result

With this integration, your form:

CaseBehavior
Valid email (is_risky: false, score < 50)Green indicator ✓, submission allowed
Suspicious email (score 50–64)Warning ⚠, submission allowed
Typo detected (did_you_mean)Interactive suggestion, submission allowed
Risky email (is_risky: true)Error ✗, submission blocked
API unavailableFail open, submission allowed
Malformed emailLocal regex error, no API call

Going Further

Protect your forms with Syvel

Block disposable, catch-all and malformed emails in real time. Simple REST API, GDPR compliant, hosted in France.