@n8x/react-form-utils
v1.2.0
Published
Reuseable form utils for instant JS object to form generation with zod validation, controlled form submission events and state management using react-hook-form
Maintainers
Readme
n8x-form-utils
A powerful 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
Table of Contents
- 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
Installation
npm install @n8x/react-form-utilsDependencies (auto-installed):
- React 17+
- React Hook Form 7+
- Zod 4+
- Tailwind CSS 4+
- Axios
Setup
1. Wrap Your App with FormContextProvider
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.
Quick Start
Create a Simple Login Form
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: '[email protected]',
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>
)
}Field Types
N8x Form Utils supports 10+ field types. Here's a comprehensive example showing all available field types:
Basic Fields
TEXT Field
{
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')
}EMAIL Field
{
name: 'email',
type: FormFieldTypes.EMAIL,
label: 'Email Address',
placeholder: '[email protected]',
required: true,
width: FormFieldWidth.FULL,
validationScheme: z.string().email('Invalid email address')
}PASSWORD Field
{
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')
}NUMBER Field
{
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')
}DATE Field
{
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'
)
}Composite Fields
SELECT Field
{
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')
}RADIO Field
{
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')
}CHECKBOX Field
{
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')
}TEXTAREA Field
{
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')
}FILE Field
{
name: 'profilePhoto',
type: FormFieldTypes.FILE,
label: 'Profile Photo',
width: FormFieldWidth.FULL,
validationScheme: z.any()
}IP address field
{
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,
},
}Responsive Design
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.
Static Width (No Responsiveness)
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')
}Responsive Width Configuration
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')
}Available Breakpoints
| 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 |
FormFieldWidth Values
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)
}Responsive Layout Examples
Example 1: Expanding Layout (Mobile → Desktop)
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
}
}Example 2: Contracting Layout
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
}
}Example 3: Complex Responsive Pattern
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
}Complete Responsive Form Example
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')
}
]Tips for Responsive Design
- 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
Validation
N8x Form Utils uses Zod for type-safe schema validation. All fields support a validationScheme property where you define your validation rules.
Basic Validation Examples
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')Complete Registration Form with Validation
// 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: '[email protected]',
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')
}
]Styling Themes
N8x Form Utils provides 4 pre-built styling themes via EDefaultFieldStyles. You can apply a theme through the defaultFieldStyles prop in the N8xForm component.
Available Themes
CLEAN (Minimal, professional)
<N8xForm
fields={loginForm}
name="loginForm"
defaultFieldStyles={EDefaultFieldStyles.CLEAN}
onSubmit={handleSubmit}
/>Clean white inputs with thin borders, subtle focus effects, and a professional appearance.
SOFT_GLASS (Modern, glassmorphism)
<N8xForm
fields={loginForm}
name="loginForm"
defaultFieldStyles={EDefaultFieldStyles.SOFT_GLASS}
onSubmit={handleSubmit}
/>Soft, frosted appearance with light background and smooth transitions. Default theme.
DARK (Dark mode)
<N8xForm
fields={loginForm}
name="loginForm"
defaultFieldStyles={EDefaultFieldStyles.DARK}
onSubmit={handleSubmit}
/>Dark background inputs suitable for dark mode interfaces.
FLOATING_LABEL (Material Design)
<N8xForm
fields={loginForm}
name="loginForm"
defaultFieldStyles={EDefaultFieldStyles.FLOATING_LABEL}
onSubmit={handleSubmit}
/>Floating label style with animated labels on focus/input. Material Design inspired.
Custom Styling
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}
/>Grouped Forms
For complex forms with multiple sections, organize fields into logical groups. Grouped forms automatically render section headers.
Example: User Profile Form with Groups
// 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()
}
]
}Using Grouped Forms
// 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>
)
}Form Submission
useN8xFormQuery Hook
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>
)
}Hook Signature
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 Function Example
// 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 })
}Complete Submission Example with Side Effects
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>
)
}Data Fetching Hooks
useN8xQuery Hook
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}
/>
)
}Hook Signature
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
Key Differences
| 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 |
Save Form Progress
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.
How It Works
- 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
Basic Usage
Simply add saveProgress: true to any field configuration:
export const checkoutForm: FormFields[] = [
{
name: 'email',
type: FormFieldTypes.EMAIL,
label: 'Email Address',
placeholder: '[email protected]',
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
}
]Use Cases
1. Long Forms - Multi-step checkout, complex registration forms
// 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
]2. Time-Consuming Forms - Prevent data loss on accidental refresh
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
}
]3. Selective Saving - Only save sensitive data that takes time to re-enter
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
}
]Complete Example: Checkout Form with Progress Saving
// 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: '[email protected]',
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
}
]Using with Component
// 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 Details
- 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)
Best Practices
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
Important Notes
- 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
Error Handling
Display Validation Errors
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)Display API Errors
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>
)}
</>
)Custom Error Component
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)
}}
/>
</>
)
}Error Recovery
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}
/>
</>
)API Reference
N8xForm Component
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)
FormFields Type
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
}FormFieldTypes Enum
enum FormFieldTypes {
TEXT = 'text'
NUMBER = 'number'
EMAIL = 'email'
PASSWORD = 'password'
SELECT = 'select'
RADIO = 'radio'
CHECKBOX = 'checkbox'
TEXTAREA = 'textarea'
DATE = 'date'
FILE = 'file'
SUBMIT = 'submit'
}FormFieldWidth Enum
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
}EDefaultFieldStyles Enum
enum EDefaultFieldStyles {
CLEAN // Minimal white inputs
SOFT_GLASS // Glassmorphism (default)
DARK // Dark mode inputs
FLOATING_LABEL // Material Design floating labels
}useN8xFormQuery Hook
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)
}useN8xQuery Hook
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)FormContextType Interface
interface FormContextType {
formUtils: ReturnType<typeof useForm> // React Hook Form utilities
setValidationSchema: (props: ZodSchema) => void // Update validation schema
setMode: (props: Mode) => void // Update validation mode
}Complete Real-World Example
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: '[email protected]',
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>
)
}Browser Support
- Chrome (latest)
- Firefox (latest)
- Safari (latest)
- Edge (latest)
License
ISC License - Created by Syed Shayan Ali
Contributing
This project is currently not open for external contributions. It will be made open source very soon in the future once it has matured and gained wider adoption.
