A powerful open source React form library that simplifies form creation with instant JavaScript object-to-form generation, Zod validation, controlled form submission events, and state management using React Hook Form. A fastest way to create and manage forms in react.
Features:
- Rapid form generation from simple field configuration
- Built-in Zod validation with type safety
- Multiple pre-built styling themes
- Grouped form support for complex layouts
- Controlled form submission handling
- Support for 10+ field types
- Query hooks for API integration and form submissions
- Automatic form progress saving to localStorage (per-field)
- New: Mobile-first responsive design control
Todo:
- Adding support for both tailwind and vanilla css.
- Query hooks further improvements (e:g caching, retries)
- Add and improve existing styles of form (In type EDefaultFieldStyles)
- Saving progress on file uploads
- File upload utils (compressor)
- Installation
- Setup
- Quick Start
- Field Types
- Responsive Design
- Validation
- Styling Themes
- Grouped Forms
- Form Submission
- Data Fetching Hooks
- Save Form Progress
- Error Handling
- API Reference
- Contribution
npm install @n8x/react-form-utilsDependencies (auto-installed):
- React 17+
- React Hook Form 7+
- Zod 4+
- Tailwind CSS 4+
- Axios
import { ReactNode } from 'react'
import { FormContext } from '@n8x/react-form-utils'
import FormContextProvider from '@n8x/react-form-utils'
export default function App() {
return (
<FormContextProvider>
{/* Your routes and components */}
</FormContextProvider>
)
}The FormContextProvider manages the global form state and validation schema. It should wrap your entire application or at least the components using N8xForm.
Step 1: Define your form fields
// forms/login.ts
import { z } from '@n8x/react-form-utils'
import { FormFieldTypes, FormFieldWidth, type FormFields } from '@n8x/react-form-utils'
export const loginForm: FormFields[] = [
{
name: 'email',
type: FormFieldTypes.EMAIL,
label: 'Email',
placeholder: 'hello@example.com',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().email('Please enter a valid email')
},
{
name: 'password',
type: FormFieldTypes.PASSWORD,
label: 'Password',
placeholder: 'Enter your password',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().min(6, 'Password must be at least 6 characters')
}
]Step 2: Create the authentication service for your login form submission
// service/auth-service.ts
import axios from './index'; // configured as per your use case
const login = async (payload: AuthRequest) => {
return await axios.post("/auth/login", payload)
}
export {
login,
}Step 3: Use the form in your component
// pages/auth/Login.tsx
import { useContext } from 'react'
import { N8xForm, useN8xFormQuery, EDefaultFieldStyles } from '@n8x/react-form-utils'
import { loginForm } from '../../forms/login'
import { login } from '../../service/auth-service'
export default function LoginPage() {
const { data, execute, error, isLoading } = useN8xFormQuery(login)
const handleLoginSubmit = async (formData: any) => {
execute(formData)
}
return (
<div className="w-full min-h-screen flex justify-center items-center">
<div className="md:w-[600px] w-full sm:p-6 p-3">
<h1 className="text-4xl tracking-tighter">Account Login</h1>
<N8xForm
fields={loginForm}
name="loginForm"
onSubmit={handleLoginSubmit}
defaultFieldStyles={EDefaultFieldStyles.FLOATING_LABEL}
>
<button
disabled={isLoading}
type="submit"
className="col-span-4 bg-blue-500 disabled:bg-zinc-400 text-white py-2 px-4 rounded font-medium"
>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</N8xForm>
{error && <p className="text-red-500 text-center mt-4">{error}</p>}
</div>
</div>
)
}N8x Form Utils supports 10+ field types. Here's a comprehensive example showing all available field types:
{
name: 'username',
type: FormFieldTypes.TEXT,
label: 'Username',
placeholder: 'Enter your username',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().min(3, 'Username must be at least 3 characters')
}{
name: 'email',
type: FormFieldTypes.EMAIL,
label: 'Email Address',
placeholder: 'you@example.com',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().email('Invalid email address')
}{
name: 'password',
type: FormFieldTypes.PASSWORD,
label: 'Password',
placeholder: 'Create a strong password',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string()
.min(6, 'Password must be at least 6 characters')
.max(36, 'Password too long')
}{
name: 'age',
type: FormFieldTypes.NUMBER,
label: 'Age',
placeholder: 'Enter your age',
min: 18,
max: 100,
required: true,
width: FormFieldWidth.HALF,
validationScheme: z.number().min(18, 'Must be 18 or older')
}{
name: 'birthDate',
type: FormFieldTypes.DATE,
label: 'Date of Birth',
required: true,
width: FormFieldWidth.HALF,
validationScheme: z.string().refine(
(date) => new Date(date) < new Date(),
'Date must be in the past'
)
}{
name: 'country',
type: FormFieldTypes.SELECT,
label: 'Country',
required: true,
width: FormFieldWidth.FULL,
options: [
{ label: 'United States', value: 'US' },
{ label: 'Canada', value: 'CA' },
{ label: 'United Kingdom', value: 'GB' },
{ label: 'Australia', value: 'AU' }
],
validationScheme: z.string().min(1, 'Please select a country')
}{
name: 'accountType',
type: FormFieldTypes.RADIO,
label: 'Account Type',
required: true,
width: FormFieldWidth.FULL,
options: [
{ label: 'Personal', value: 'personal' },
{ label: 'Business', value: 'business' },
{ label: 'Enterprise', value: 'enterprise' }
],
validationScheme: z.string().min(1, 'Please select an account type')
}{
name: 'interests',
type: FormFieldTypes.CHECKBOX,
label: 'Interests',
width: FormFieldWidth.FULL,
options: [
{ label: 'Technology', value: 'tech' },
{ label: 'Finance', value: 'finance' },
{ label: 'Health', value: 'health' },
{ label: 'Travel', value: 'travel' }
],
validationScheme: z.array(z.string()).min(1, 'Select at least one interest')
}{
name: 'bio',
type: FormFieldTypes.TEXTAREA,
label: 'Bio',
placeholder: 'Tell us about yourself',
width: FormFieldWidth.FULL,
validationScheme: z.string()
.min(10, 'Bio must be at least 10 characters')
.max(500, 'Bio must not exceed 500 characters')
}{
name: 'profilePhoto',
type: FormFieldTypes.FILE,
label: 'Profile Photo',
width: FormFieldWidth.FULL,
validationScheme: z.any()
}{
name: "ip_address",
type: FormFieldTypes.TEXT,
label: "IP Address",
placeholder: "192.168.0.1",
validationScheme: z.string().ip("Please enter a valid IP address"),
width: {
base: FormFieldWidth.FULL,
md: FormFieldWidth.HALF,
lg: FormFieldWidth.QUARTER,
xl: FormFieldWidth.FULL,
},
}N8x Form Utils provides mobile-first responsive width configuration using Tailwind CSS breakpoints. Fields can adapt their layout across different screen sizes without any additional styling code.
For simple cases, use static width values:
{
name: 'email',
type: FormFieldTypes.EMAIL,
label: 'Email',
width: FormFieldWidth.HALF, // Always takes 50% width
validationScheme: z.string().email('Invalid email')
}Use the responsive width object to define different widths for different screen sizes. The form uses a mobile-first approach, starting with a base width and overriding it at larger breakpoints.
{
name: 'email',
type: FormFieldTypes.EMAIL,
label: 'Email',
width: {
base: FormFieldWidth.FULL, // Mobile: 100% width (col-span-4)
md: FormFieldWidth.HALF, // Tablet 768px+: 50% width (col-span-2)
lg: FormFieldWidth.QUARTER // Desktop 1024px+: 25% width (col-span-1)
},
validationScheme: z.string().email('Invalid email')
}| Breakpoint | Screen Size | Tailwind Class | Usage |
|---|---|---|---|
base |
0px+ (mobile) | - | Default width for all screen sizes |
sm |
640px | sm: | Small mobile devices |
md |
768px | md: | Tablets |
lg |
1024px | lg: | Laptops & desktops |
xl |
1280px | xl: | Large desktops |
2xl |
1536px | 2xl: | Extra large screens |
export enum FormFieldWidth {
FULL = 'col-span-4', // 100% width (4 columns)
HALF = 'col-span-2', // 50% width (2 columns)
THIRD = 'col-span-3', // 75% width (3 columns)
QUARTER = 'col-span-1' // 25% width (1 column)
}Fields grow from full width on mobile to quarter width on desktop:
{
name: 'firstName',
type: FormFieldTypes.TEXT,
label: 'First Name',
width: {
base: FormFieldWidth.FULL, // Mobile: full width
md: FormFieldWidth.HALF, // Tablet: half width
lg: FormFieldWidth.QUARTER // Desktop: quarter width
}
}Fields shrink from full width on mobile to quarter width on tablet:
{
name: 'phone',
type: FormFieldTypes.TEXT,
label: 'Phone Number',
width: {
base: FormFieldWidth.FULL, // Mobile: full width
md: FormFieldWidth.QUARTER, // Tablet: quarter width
lg: FormFieldWidth.HALF // Desktop: half width
}
}IP address field with varying widths across all breakpoints:
{
name: 'ip_address',
type: FormFieldTypes.TEXT,
label: 'IP Address',
placeholder: '192.168.0.1',
width: {
base: FormFieldWidth.FULL, // Mobile: full width
md: FormFieldWidth.HALF, // Tablet 768px+: half width
lg: FormFieldWidth.QUARTER, // Desktop 1024px+: quarter width
xl: FormFieldWidth.FULL // Large desktop 1280px+: full width
},
validationScheme: z.string().ip('Please enter a valid IP address'),
saveProgress: true
}import { z } from 'zod/v3'
import { FormFieldTypes, FormFieldWidth, type FormFields } from '@n8x/react-form-utils'
export const responsiveContactForm: FormFields[] = [
{
name: 'firstName',
type: FormFieldTypes.TEXT,
label: 'First Name',
required: true,
width: {
base: FormFieldWidth.FULL,
md: FormFieldWidth.HALF,
lg: FormFieldWidth.QUARTER
},
validationScheme: z.string().min(2, 'First name required')
},
{
name: 'lastName',
type: FormFieldTypes.TEXT,
label: 'Last Name',
required: true,
width: {
base: FormFieldWidth.FULL,
md: FormFieldWidth.HALF,
lg: FormFieldWidth.QUARTER
},
validationScheme: z.string().min(2, 'Last name required')
},
{
name: 'email',
type: FormFieldTypes.EMAIL,
label: 'Email Address',
required: true,
width: {
base: FormFieldWidth.FULL,
md: FormFieldWidth.HALF,
lg: FormFieldWidth.HALF
},
validationScheme: z.string().email('Invalid email')
},
{
name: 'phone',
type: FormFieldTypes.TEXT,
label: 'Phone Number',
width: {
base: FormFieldWidth.FULL,
md: FormFieldWidth.HALF,
lg: FormFieldWidth.QUARTER
},
validationScheme: z.string().optional()
},
{
name: 'message',
type: FormFieldTypes.TEXTAREA,
label: 'Message',
placeholder: 'Your message here...',
width: {
base: FormFieldWidth.FULL,
md: FormFieldWidth.FULL,
lg: FormFieldWidth.FULL
},
validationScheme: z.string().min(10, 'Message must be at least 10 characters')
}
]- Always define
base- This is your mobile-first default - Build up, not down - Start with mobile and add breakpoints for larger screens
- Test all breakpoints - Check your forms on actual devices or use browser dev tools
- Keep consistency - Use the same breakpoint structure across related fields
- Group related fields - Fields in the same section should have complementary responsive widths
N8x Form Utils uses Zod for type-safe schema validation. All fields support a validationScheme property where you define your validation rules.
import { z } from '@n8x/react-form-utils'
// Email validation
validationScheme: z.string().email('Invalid email address')
// Required string with minimum length
validationScheme: z.string().min(3, 'Minimum 3 characters required')
// Number with range
validationScheme: z.number().min(0).max(100, 'Value must be between 0 and 100')
// Custom refine validation
validationScheme: z.string().refine(
(value) => value !== 'admin',
'Username cannot be "admin"'
)
// Conditional validation
validationScheme: z.string().or(z.number())
// Array validation
validationScheme: z.array(z.string()).min(1, 'Select at least one option')// forms/register.ts
import { z } from '@n8x/react-form-utils'
import { FormFieldTypes, FormFieldWidth, type FormFields } from '@n8x/react-form-utils'
export const registerForm: FormFields[] = [
{
name: 'email',
type: FormFieldTypes.EMAIL,
label: 'Email',
placeholder: 'hello@example.com',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().email('Please enter a valid email')
},
{
name: 'password',
type: FormFieldTypes.PASSWORD,
label: 'Password',
placeholder: 'Create a strong password',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string()
.min(6, 'Password must be at least 6 characters')
.max(36, 'Password too long')
},
{
name: 'confirmPassword',
type: FormFieldTypes.PASSWORD,
label: 'Confirm Password',
placeholder: 'Re-enter your password',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().min(6, 'Password must be at least 6 characters')
},
{
name: 'fullName',
type: FormFieldTypes.TEXT,
label: 'Full Name',
placeholder: 'John Doe',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().min(2, 'Name must be at least 2 characters')
},
{
name: 'agreeToTerms',
type: FormFieldTypes.CHECKBOX,
label: 'Terms',
width: FormFieldWidth.FULL,
options: [
{ label: 'I agree to the Terms and Conditions', value: 'agree' }
],
validationScheme: z.array(z.string()).min(1, 'You must agree to the terms')
}
]N8x Form Utils provides 4 pre-built styling themes via EDefaultFieldStyles. You can apply a theme through the defaultFieldStyles prop in the N8xForm component.
<N8xForm
fields={loginForm}
name="loginForm"
defaultFieldStyles={EDefaultFieldStyles.CLEAN}
onSubmit={handleSubmit}
/>Clean white inputs with thin borders, subtle focus effects, and a professional appearance.
<N8xForm
fields={loginForm}
name="loginForm"
defaultFieldStyles={EDefaultFieldStyles.SOFT_GLASS}
onSubmit={handleSubmit}
/>Soft, frosted appearance with light background and smooth transitions. Default theme.
<N8xForm
fields={loginForm}
name="loginForm"
defaultFieldStyles={EDefaultFieldStyles.DARK}
onSubmit={handleSubmit}
/>Dark background inputs suitable for dark mode interfaces.
<N8xForm
fields={loginForm}
name="loginForm"
defaultFieldStyles={EDefaultFieldStyles.FLOATING_LABEL}
onSubmit={handleSubmit}
/>Floating label style with animated labels on focus/input. Material Design inspired.
You can also apply custom Tailwind CSS classes to individual fields:
{
name: 'customField',
type: FormFieldTypes.TEXT,
label: 'Custom Styled',
_className: 'bg-gradient-to-r from-blue-400 to-blue-600 text-white rounded-xl'
}Or pass a custom string:
<N8xForm
fields={loginForm}
name="loginForm"
defaultFieldStyles="custom-class-string"
onSubmit={handleSubmit}
/>For complex forms with multiple sections, organize fields into logical groups. Grouped forms automatically render section headers.
// forms/userProfile.ts
import { z } from '@n8x/react-form-utils'
import { FormFieldTypes, FormFieldWidth, type FormFields } from '@n8x/react-form-utils'
export const userProfileForm = {
'Personal Information': [
{
name: 'firstName',
type: FormFieldTypes.TEXT,
label: 'First Name',
required: true,
width: FormFieldWidth.HALF,
validationScheme: z.string().min(2, 'First name is required')
},
{
name: 'lastName',
type: FormFieldTypes.TEXT,
label: 'Last Name',
required: true,
width: FormFieldWidth.HALF,
validationScheme: z.string().min(2, 'Last name is required')
},
{
name: 'email',
type: FormFieldTypes.EMAIL,
label: 'Email Address',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().email('Invalid email')
},
{
name: 'birthDate',
type: FormFieldTypes.DATE,
label: 'Date of Birth',
required: false,
width: FormFieldWidth.HALF,
validationScheme: z.string().optional()
}
],
'Contact Information': [
{
name: 'phone',
type: FormFieldTypes.TEXT,
label: 'Phone Number',
placeholder: '+1 (555) 123-4567',
width: FormFieldWidth.FULL,
validationScheme: z.string().optional()
},
{
name: 'country',
type: FormFieldTypes.SELECT,
label: 'Country',
required: true,
width: FormFieldWidth.HALF,
options: [
{ label: 'United States', value: 'US' },
{ label: 'Canada', value: 'CA' },
{ label: 'United Kingdom', value: 'GB' }
],
validationScheme: z.string().min(1, 'Select a country')
},
{
name: 'city',
type: FormFieldTypes.TEXT,
label: 'City',
required: true,
width: FormFieldWidth.HALF,
validationScheme: z.string().min(2, 'City is required')
}
],
'Preferences': [
{
name: 'language',
type: FormFieldTypes.SELECT,
label: 'Preferred Language',
width: FormFieldWidth.FULL,
options: [
{ label: 'English', value: 'en' },
{ label: 'Spanish', value: 'es' },
{ label: 'French', value: 'fr' },
{ label: 'German', value: 'de' }
],
validationScheme: z.string().optional()
},
{
name: 'newsletter',
type: FormFieldTypes.CHECKBOX,
label: 'Email Preferences',
width: FormFieldWidth.FULL,
options: [
{ label: 'Subscribe to newsletter', value: 'newsletter' },
{ label: 'Receive promotional emails', value: 'promo' }
],
validationScheme: z.array(z.string()).optional()
}
]
}// pages/profile/UserProfile.tsx
import { useContext } from 'react'
import { N8xForm, useN8xFormQuery, EDefaultFieldStyles } from '@n8x/react-form-utils'
import { userProfileForm } from '../../forms/userProfile'
import { updateUserProfile } from '../../service/user-service'
export default function UserProfilePage() {
const { data, execute, error, isLoading } = useN8xFormQuery(updateUserProfile)
const handleProfileUpdate = async (formData: any) => {
execute(formData)
}
return (
<div className="w-full p-6">
<h1 className="text-3xl font-bold mb-6">Edit Profile</h1>
<N8xForm
fields={userProfileForm}
name="userProfileForm"
onSubmit={handleProfileUpdate}
defaultFieldStyles={EDefaultFieldStyles.SOFT_GLASS}
>
<button
disabled={isLoading}
type="submit"
className="col-span-4 bg-blue-500 disabled:bg-zinc-400 text-white py-2 px-4 rounded font-medium"
>
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
</N8xForm>
{error && <p className="text-red-500 mt-4">{error}</p>}
</div>
)
}Execute async operations when the form is submitted. Perfect for API calls.
import { useN8xFormQuery } from '@n8x/react-form-utils'
import { login } from '../service/auth-service'
export default function LoginComponent() {
const { data, execute, error, isLoading } = useN8xFormQuery(login)
const handleSubmit = async (formData: any) => {
execute(formData) // Calls the login service
}
return (
<N8xForm
fields={loginForm}
name="loginForm"
onSubmit={handleSubmit}
>
<button disabled={isLoading} type="submit">
{isLoading ? 'Loading...' : 'Login'}
</button>
</N8xForm>
)
}const { data, execute, error, isLoading } = useN8xFormQuery(
queryService: (payload: any, signal?: AbortSignal) => Promise<any>
)Properties:
data: Response data from the service after successful executionexecute: Function to call the service with form payloaderror: Error message if the request failsisLoading: Boolean indicating if the request is in progress
// service/auth-service.ts
import axios from 'axios'
export const login = async (payload: any, signal?: AbortSignal) => {
return axios.post('/api/auth/login', payload, { signal })
}
export const register = async (payload: any, signal?: AbortSignal) => {
return axios.post('/api/auth/register', payload, { signal })
}import { useContext, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { N8xForm, useN8xFormQuery, EDefaultFieldStyles } from '@n8x/react-form-utils'
import { AuthContext } from '../context/AuthContext'
import { loginForm } from '../forms/login'
import { login } from '../service/auth-service'
export default function LoginPage() {
const { setUser } = useContext(AuthContext)
const navigate = useNavigate()
const { data, execute, error, isLoading } = useN8xFormQuery(login)
const handleLoginSubmit = async (formData: any) => {
execute(formData)
}
// Handle successful login
useEffect(() => {
if (data) {
setUser(data)
navigate('/home')
}
}, [data, setUser, navigate])
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-full max-w-md">
<h1 className="text-4xl font-bold mb-6">Login</h1>
<N8xForm
fields={loginForm}
name="loginForm"
onSubmit={handleLoginSubmit}
defaultFieldStyles={EDefaultFieldStyles.FLOATING_LABEL}
>
<button
disabled={isLoading}
type="submit"
className="col-span-4 bg-blue-500 hover:bg-blue-600 disabled:bg-zinc-400 text-white py-2 rounded font-medium transition"
>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</N8xForm>
{error && <p className="text-red-500 text-center mt-4">{error}</p>}
</div>
</div>
)
}Auto-execute queries on component mount. Use this for fetching initial data to populate forms.
import { useN8xQuery } from '@n8x/react-form-utils'
import { fetchUserProfile } from '../service/user-service'
export default function ProfilePage() {
const { data: profile, error, isLoading } = useN8xQuery(fetchUserProfile, userId)
if (isLoading) return <div>Loading profile...</div>
if (error) return <div>Error: {error}</div>
return (
<N8xForm
fields={userProfileForm}
name="userProfileForm"
onSubmit={handleProfileUpdate}
/>
)
}const { data, error, isLoading } = useN8xQuery(
queryService: (payload: any, signal?: AbortSignal) => Promise<any>,
...queryParams: any[]
)Properties:
data: Response data from the serviceerror: Error message if the request failsisLoading: Boolean indicating if the request is loading
| Feature | useN8xFormQuery | useN8xQuery |
|---|---|---|
| Runs on mount | ❌ No | ✅ Yes |
| Triggered by | Manual call | Auto on mount |
| Return state | data, error, isLoading, execute |
data, error, isLoading |
| Use case | Form submissions | Initial data fetch |
N8x Form Utils includes built-in automatic form progress saving to localStorage. This feature allows you to save individual field values automatically, enabling users to resume filling out forms even after browser refresh or accidental navigation.
- Fields with
saveProgress: trueare automatically saved to localStorage - Saves are debounced (2 second delay) for optimal performance
- Saved values are automatically restored when the form mounts
- Saved progress is automatically cleared after successful form submission
- Works per-field (granular control) — only fields you mark will be saved
Simply add saveProgress: true to any field configuration:
export const checkoutForm: FormFields[] = [
{
name: 'email',
type: FormFieldTypes.EMAIL,
label: 'Email Address',
placeholder: 'you@example.com',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().email('Invalid email'),
saveProgress: true // Enable progress saving for this field
},
{
name: 'cardNumber',
type: FormFieldTypes.TEXT,
label: 'Card Number',
placeholder: '1234 5678 9012 3456',
required: true,
width: FormFieldWidth.FULL,
saveProgress: true // Save this field too
},
{
name: 'cardCVC',
type: FormFieldTypes.TEXT,
label: 'CVC',
placeholder: '123',
required: true,
width: FormFieldWidth.QUARTER,
saveProgress: true
}
]// Save critical fields in a lengthy form
export const registrationForm: FormFields[] = [
{
name: 'email',
type: FormFieldTypes.EMAIL,
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().email(),
saveProgress: true // Don't make users re-enter email
},
{
name: 'fullName',
type: FormFieldTypes.TEXT,
required: true,
width: FormFieldWidth.FULL,
saveProgress: true
},
// ... more fields
]export const surveyForm: FormFields[] = [
{
name: 'feedback',
type: FormFieldTypes.TEXTAREA,
label: 'Your Feedback',
placeholder: 'Share your thoughts...',
width: FormFieldWidth.FULL,
validationScheme: z.string().min(10),
saveProgress: true // Auto-save in case of browser crash
}
]export const profileUpdateForm: FormFields[] = [
{
name: 'username',
type: FormFieldTypes.TEXT,
required: true,
saveProgress: true // Save username
},
{
name: 'bio',
type: FormFieldTypes.TEXTAREA,
saveProgress: true // Save bio
},
{
name: 'profilePicture',
type: FormFieldTypes.FILE,
// No saveProgress — files aren't saved to localStorage
},
{
name: 'securityToken',
type: FormFieldTypes.TEXT,
// No saveProgress — security tokens should not be persisted
}
]// forms/checkout.ts
import { z } from '@n8x/react-form-utils'
import { FormFieldTypes, FormFieldWidth, type FormFields } from '@n8x/react-form-utils'
export const checkoutForm: FormFields[] = [
// Billing Information (all saved)
{
name: 'email',
type: FormFieldTypes.EMAIL,
label: 'Email Address',
placeholder: 'you@example.com',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().email('Invalid email'),
saveProgress: true
},
{
name: 'fullName',
type: FormFieldTypes.TEXT,
label: 'Full Name',
placeholder: 'John Doe',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().min(2, 'Name too short'),
saveProgress: true
},
{
name: 'address',
type: FormFieldTypes.TEXT,
label: 'Street Address',
placeholder: '123 Main St',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().min(5),
saveProgress: true
},
{
name: 'city',
type: FormFieldTypes.TEXT,
label: 'City',
placeholder: 'New York',
required: true,
width: FormFieldWidth.HALF,
validationScheme: z.string().min(2),
saveProgress: true
},
{
name: 'zipCode',
type: FormFieldTypes.TEXT,
label: 'ZIP Code',
placeholder: '10001',
required: true,
width: FormFieldWidth.HALF,
validationScheme: z.string().min(5),
saveProgress: true
},
// Payment Information (selectively saved)
{
name: 'cardNumber',
type: FormFieldTypes.TEXT,
label: 'Card Number',
placeholder: '1234 5678 9012 3456',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().length(16),
saveProgress: true
},
{
name: 'cardExpiry',
type: FormFieldTypes.TEXT,
label: 'Expiry Date',
placeholder: 'MM/YY',
required: true,
width: FormFieldWidth.HALF,
validationScheme: z.string().length(5),
saveProgress: true
},
{
name: 'cardCVC',
type: FormFieldTypes.TEXT,
label: 'CVC',
placeholder: '123',
required: true,
width: FormFieldWidth.HALF,
validationScheme: z.string().length(3),
// NOT saving CVC for security reasons
saveProgress: false
}
]// pages/checkout/Checkout.tsx
import { useState } from 'react'
import { N8xForm, useN8xFormQuery, EDefaultFieldStyles } from '@n8x/react-form-utils'
import { checkoutForm } from '../../forms/checkout'
import { processPayment } from '../../service/payment-service'
export default function CheckoutPage() {
const { data, execute, error, isLoading } = useN8xFormQuery(processPayment)
const [isRecovering, setIsRecovering] = useState(false)
const handleCheckoutSubmit = async (formData: any) => {
setIsRecovering(false)
execute(formData)
}
return (
<div className="w-full max-w-2xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2">Checkout</h1>
{isRecovering && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-700">
✓ Your previous form data has been restored
</p>
</div>
)}
</div>
<N8xForm
fields={checkoutForm}
name="checkoutForm"
onSubmit={handleCheckoutSubmit}
defaultFieldStyles={EDefaultFieldStyles.SOFT_GLASS}
>
<button
disabled={isLoading}
type="submit"
className="col-span-4 bg-blue-500 hover:bg-blue-600 disabled:bg-zinc-400 text-white py-3 px-4 rounded-lg font-semibold transition"
>
{isLoading ? 'Processing...' : 'Complete Purchase'}
</button>
</N8xForm>
{error && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700 font-semibold">Payment failed</p>
<p className="text-red-600 text-sm mt-1">{error}</p>
</div>
)}
</div>
)
}- Storage Key: Form values are stored using the form's
nameprop as the key - Storage Limit: Respects browser localStorage limits (~5-10MB per domain)
- Format: Data stored as JSON object:
{ fieldName: fieldValue, ... } - Persistence: Data persists across browser sessions until form submission
- Clearing: Automatic deletion after successful submit (
deleteLocalStoragecalled internally)
-
Don't Save Sensitive Data
// ❌ Bad: Saving sensitive information saveProgress: true // For password fields? No! // ✅ Good: Skip security-sensitive fields { name: 'cvv', type: FormFieldTypes.TEXT, saveProgress: false // Omit for sensitive data }
-
Use for User Convenience
// ✅ Good: Save fields that take time to fill { name: 'biography', type: FormFieldTypes.TEXTAREA, saveProgress: true } // ✅ Good: Save contact information { name: 'email', type: FormFieldTypes.EMAIL, saveProgress: true }
-
Monitor Performance
- Progress saving uses a 1-second debounce
- Multiple field changes within 1 second are batched into a single save
- Minimal performance impact for typical forms
-
Test Data Loss Scenarios
// Simulate recovery by refreshing browser mid-form fill // Your data should persist and be restored automatically
- localStorage Cleared on Submit: All saved progress for a form is automatically deleted when the form is successfully submitted
- Per-Field Control: You have granular control — enable it only for fields that benefit from it
- No Index DB Support: Currently uses localStorage only; large files cannot be saved
- Browser Dependent: Data is stored per browser/device; not synced across devices
- Private Browsing: May not work in private/incognito mode depending on browser settings
Validation errors from Zod are automatically displayed below each field with error styling.
<N8xForm
fields={loginForm}
name="loginForm"
onSubmit={handleSubmit}
/>
// Errors appear in real-time as user types (default: "onChange" mode)Handle errors returned from useN8xFormQuery:
const { data, execute, error, isLoading } = useN8xFormQuery(login)
return (
<>
<N8xForm
fields={loginForm}
name="loginForm"
onSubmit={handleSubmit}
/>
{error && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded">
<p className="text-red-700 font-semibold">Login failed</p>
<p className="text-red-600 text-sm">{error}</p>
</div>
)}
</>
)interface ErrorProps {
message: string
onDismiss?: () => void
}
function ErrorAlert({ message, onDismiss }: ErrorProps) {
return (
<div className="p-4 bg-red-50 border-l-4 border-red-500 rounded">
<p className="text-red-700">{message}</p>
{onDismiss && (
<button onClick={onDismiss} className="mt-2 text-sm text-red-600">
Dismiss
</button>
)}
</div>
)
}
export default function LoginPage() {
const { data, execute, error, isLoading } = useN8xFormQuery(login)
const [dismissError, setDismissError] = React.useState(false)
return (
<>
{error && !dismissError && (
<ErrorAlert
message={error}
onDismiss={() => setDismissError(true)}
/>
)}
<N8xForm
fields={loginForm}
name="loginForm"
onSubmit={(data) => {
setDismissError(false)
execute(data)
}}
/>
</>
)
}const [errorKey, setErrorKey] = React.useState(0)
const handleRetry = () => {
setErrorKey(prev => prev + 1) // Force form re-render
}
return (
<>
{error && (
<button onClick={handleRetry} className="text-blue-600">
Retry
</button>
)}
<N8xForm
key={errorKey}
fields={loginForm}
name="loginForm"
onSubmit={handleSubmit}
/>
</>
)The main form component that renders fields and handles submission.
interface FormProps {
fields?: FormFields[] | GroupedFormFields
name?: string
defaultFieldStyles?: EDefaultFieldStyles | string
onSubmit: (data: any) => void
mode?: Mode
children?: React.ReactNode
}
<N8xForm
fields={loginForm}
name="loginForm"
defaultFieldStyles={EDefaultFieldStyles.FLOATING_LABEL}
onSubmit={handleSubmit}
mode="onBlur"
>
<button type="submit">Submit</button>
</N8xForm>Props:
fields: Array or object of FormFields to rendername: Unique identifier for the form (used for localStorage progress saving)defaultFieldStyles: Theme from EDefaultFieldStyles or custom Tailwind stringonSubmit: Callback when form is successfully submittedmode: React Hook Form validation mode ('onChange', 'onBlur', 'onTouched', 'onSubmit', 'all')children: Additional JSX elements (e.g., submit button, custom elements)
Configuration for a single form field.
type FormFields = {
name: string // Unique field identifier
type: FormFieldTypes // Input type
validationScheme?: ZodTypeAny // Zod validation schema
label?: string // Display label
placeholder?: string // Input placeholder
options?: { label: string; value: string }[] // For select/radio/checkbox
min?: number // Min value for number fields
max?: number // Max value for number fields
required?: boolean // Is field required?
width?: FormFieldWidth // Grid column span
watch?: boolean // Watch for changes
default?: string // Default value
saveProgress?: boolean // Auto-save to localStorage on change
_className?: string // Custom Tailwind classes
}enum FormFieldTypes {
TEXT = 'text'
NUMBER = 'number'
EMAIL = 'email'
PASSWORD = 'password'
SELECT = 'select'
RADIO = 'radio'
CHECKBOX = 'checkbox'
TEXTAREA = 'textarea'
DATE = 'date'
FILE = 'file'
SUBMIT = 'submit'
}enum FormFieldWidth {
FULL = 'col-span-4' // 100% width
HALF = 'col-span-2' // 50% width
THIRD = 'col-span-3' // 75% width
QUARTER = 'col-span-1' // 25% width
}enum EDefaultFieldStyles {
CLEAN // Minimal white inputs
SOFT_GLASS // Glassmorphism (default)
DARK // Dark mode inputs
FLOATING_LABEL // Material Design floating labels
}const {
data, // Response data after successful request
execute, // Function to execute the query
error, // Error message string or null
isLoading // Boolean loading state
} = useN8xFormQuery(
queryService: (payload: any, signal?: AbortSignal) => Promise<any>
)Example:
const { data, execute, error, isLoading } = useN8xFormQuery(login)
// Later in form submission:
const handleSubmit = (formData: any) => {
execute(formData)
}const {
data, // Response data after successful request
error, // Error message string or null
isLoading // Boolean loading state
} = useN8xQuery(
queryService: (payload: any, signal?: AbortSignal) => Promise<any>,
...queryParams: any[]
)Example:
const { data, error, isLoading } = useN8xQuery(fetchUserProfile, userId)interface FormContextType {
formUtils: ReturnType<typeof useForm> // React Hook Form utilities
setValidationSchema: (props: ZodSchema) => void // Update validation schema
setMode: (props: Mode) => void // Update validation mode
}Here's a complete registration flow combining everything:
// forms/registration.ts
import { z } from '@n8x/react-form-utils'
import { FormFieldTypes, FormFieldWidth, type FormFields } from '@n8x/react-form-utils'
export const registrationForm: FormFields[] = [
{
name: 'email',
type: FormFieldTypes.EMAIL,
label: 'Email Address',
placeholder: 'hello@example.com',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().email('Invalid email address')
},
{
name: 'password',
type: FormFieldTypes.PASSWORD,
label: 'Password',
placeholder: 'Create a strong password',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string()
.min(6, 'Password must be at least 6 characters')
.max(36, 'Password too long')
},
{
name: 'confirmPassword',
type: FormFieldTypes.PASSWORD,
label: 'Confirm Password',
placeholder: 'Re-enter your password',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string()
.min(6, 'Password must match')
},
{
name: 'fullName',
type: FormFieldTypes.TEXT,
label: 'Full Name',
placeholder: 'John Doe',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().min(2, 'Name too short')
},
{
name: 'accountType',
type: FormFieldTypes.RADIO,
label: 'Account Type',
required: true,
width: FormFieldWidth.FULL,
options: [
{ label: 'Personal', value: 'personal' },
{ label: 'Business', value: 'business' }
],
validationScheme: z.string()
},
{
name: 'interests',
type: FormFieldTypes.CHECKBOX,
label: 'Select Your Interests',
width: FormFieldWidth.FULL,
options: [
{ label: 'Technology', value: 'tech' },
{ label: 'Finance', value: 'finance' },
{ label: 'Health', value: 'health' }
],
validationScheme: z.array(z.string()).min(1, 'Select at least one interest')
},
{
name: 'terms',
type: FormFieldTypes.CHECKBOX,
label: 'Agreement',
required: true,
width: FormFieldWidth.FULL,
options: [
{ label: 'I agree to Terms and Conditions', value: 'agreed' }
],
validationScheme: z.array(z.string()).min(1, 'You must agree to terms')
}
]
// pages/auth/Register.tsx
import { useContext, useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { N8xForm, useN8xFormQuery, EDefaultFieldStyles } from '@n8x/react-form-utils'
import { AuthContext, type IAuthContext } from '../../context/AuthContext/context'
import { registrationForm } from '../../forms/registration'
import { register } from '../../service/auth-service'
export default function RegisterPage() {
const { user, setUser } = useContext(AuthContext) as IAuthContext
const navigate = useNavigate()
const [dismissError, setDismissError] = useState(false)
const { data, execute, error, isLoading } = useN8xFormQuery(register)
const handleRegisterSubmit = async (formData: any) => {
// Validate passwords match
if (formData.password !== formData.confirmPassword) {
// You could set a custom error here or let Zod handle it
return
}
setDismissError(false)
execute(formData)
}
// Handle successful registration
useEffect(() => {
if (data) {
setUser(data)
navigate('/home')
}
}, [data, setUser, navigate])
// Redirect if already logged in
useEffect(() => {
if (user) {
navigate('/home')
}
}, [user, navigate])
return (
<div className="w-full min-h-screen flex justify-center items-center bg-gradient-to-br from-zinc-50 to-zinc-100 p-4">
<div className="md:w-[600px] w-full">
<div className="mb-8">
<h1 className="text-4xl font-bold tracking-tight mb-2">Create Your Account</h1>
<p className="text-zinc-600">Join us today and get started</p>
</div>
{error && !dismissError && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700 font-semibold">Registration failed</p>
<p className="text-red-600 text-sm mt-1">{error}</p>
<button
onClick={() => setDismissError(true)}
className="mt-2 text-xs text-red-600 hover:text-red-700"
>
Dismiss
</button>
</div>
)}
<N8xForm
fields={registrationForm}
name="registrationForm"
onSubmit={handleRegisterSubmit}
defaultFieldStyles={EDefaultFieldStyles.FLOATING_LABEL}
>
<button
disabled={isLoading}
type="submit"
className={`col-span-4 py-3 px-4 rounded-lg font-semibold text-white transition-all ${
isLoading
? 'bg-zinc-400 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600 active:bg-blue-700'
}`}
>
{isLoading ? (
<span className="inline-flex items-center gap-2">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Creating account...
</span>
) : (
'Create Account'
)}
</button>
</N8xForm>
<p className="text-center text-sm text-zinc-600 mt-6">
Already have an account?{' '}
<Link to="/login" className="text-blue-500 hover:text-blue-600 font-semibold">
Login here
</Link>
</p>
</div>
</div>
)
}- Chrome (latest)
- Firefox (latest)
- Safari (latest)
- Edge (latest)
ISC License - Created by Syed Shayan Ali
Contributions are welcome! Feel free to fork the repository, make your changes, and submit a pull request in new branch.