What are the rules of hooks? Design a custom hook for handling form state with validation.
Quick Answer
Custom hooks are functions that start with "use" and can call other hooks. They allow you to extract component logic into reusable functions, making your code more modular and testable.
Detailed Answer
What are the rules of hooks? Design a custom hook for handling form state with validation.
Answer: Custom hooks are functions that start with "use" and can call other hooks. They allow you to extract component logic into reusable functions, making your code more modular and testable.
Rules of Hooks:
-
Only Call Hooks at the Top Level:
- Never call hooks inside loops, conditions, or nested functions
- Always call hooks in the same order on every render
-
Only Call Hooks from React Functions:
- Call hooks from React function components
- Call hooks from other custom hooks
// ✅ GOOD - Hooks called at top level
function MyComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return <div>{count}</div>;
}
// ❌ BAD - Hooks called conditionally
function BadComponent({ shouldUseEffect }) {
const [count, setCount] = useState(0);
if (shouldUseEffect) {
useEffect(() => { // This violates the rules!
document.title = `Count: ${count}`;
}, [count]);
}
return <div>{count}</div>;
}
// ❌ BAD - Hooks called in loops
function BadComponent({ items }) {
const [count, setCount] = useState(0);
items.forEach(item => {
useEffect(() => { // This violates the rules!
console.log(item);
}, [item]);
});
return <div>{count}</div>;
}
Custom Hook for Form State with Validation:
Here's a comprehensive form hook that handles state, validation, and submission:
// Types for form validation
interface ValidationRule<T> {
required?: boolean;
minLength?: number;
maxLength?: number;
pattern?: RegExp;
custom?: (value: T) => string | undefined;
message?: string;
}
interface FormField<T> {
value: T;
error: string | undefined;
touched: boolean;
rules: ValidationRule<T>[];
}
interface FormState<T extends Record<string, any>> {
fields: { [K in keyof T]: FormField<T[K]> };
isValid: boolean;
isSubmitting: boolean;
isDirty: boolean;
}
interface UseFormOptions<T extends Record<string, any>> {
initialValues: T;
validationRules?: Partial<{ [K in keyof T]: ValidationRule<T[K]>[] }>;
onSubmit: (values: T) => Promise<void> | void;
validateOnChange?: boolean;
validateOnBlur?: boolean;
}
// Custom form hook
function useForm<T extends Record<string, any>>({
initialValues,
validationRules = {},
onSubmit,
validateOnChange = true,
validateOnBlur = true
}: UseFormOptions<T>) {
// Initialize form state
const [formState, setFormState] = useState<FormState<T>>(() => {
const fields = {} as { [K in keyof T]: FormField<T[K]> };
for (const key in initialValues) {
fields[key] = {
value: initialValues[key],
error: undefined,
touched: false,
rules: validationRules[key] || []
};
}
return {
fields,
isValid: true,
isSubmitting: false,
isDirty: false
};
});
// Validation function
const validateField = useCallback(<K extends keyof T>(
fieldName: K,
value: T[K],
rules: ValidationRule<T[K]>[]
): string | undefined => {
for (const rule of rules) {
// Required validation
if (rule.required && (value === undefined || value === null || value === '')) {
return rule.message || `${String(fieldName)} is required`;
}
// Skip other validations if value is empty and not required
if (!rule.required && (value === undefined || value === null || value === '')) {
continue;
}
// String length validations
if (typeof value === 'string') {
if (rule.minLength && value.length < rule.minLength) {
return rule.message || `${String(fieldName)} must be at least ${rule.minLength} characters`;
}
if (rule.maxLength && value.length > rule.maxLength) {
return rule.message || `${String(fieldName)} must be no more than ${rule.maxLength} characters`;
}
if (rule.pattern && !rule.pattern.test(value)) {
return rule.message || `${String(fieldName)} format is invalid`;
}
}
// Custom validation
if (rule.custom) {
const customError = rule.custom(value);
if (customError) {
return customError;
}
}
}
return undefined;
}, []);
// Update field value
const setFieldValue = useCallback(<K extends keyof T>(
fieldName: K,
value: T[K]
) => {
setFormState(prevState => {
const field = prevState.fields[fieldName];
const error = validateOnChange
? validateField(fieldName, value, field.rules)
: field.error;
const newFields = {
...prevState.fields,
[fieldName]: {
...field,
value,
error,
touched: true
}
};
// Check if form is valid
const isValid = Object.values(newFields).every(field => !field.error);
// Check if form is dirty
const isDirty = Object.keys(newFields).some(key =>
newFields[key as keyof T].value !== initialValues[key as keyof T]
);
return {
...prevState,
fields: newFields,
isValid,
isDirty
};
});
}, [validateField, validateOnChange, initialValues]);
// Set field error
const setFieldError = useCallback(<K extends keyof T>(
fieldName: K,
error: string | undefined
) => {
setFormState(prevState => ({
...prevState,
fields: {
...prevState.fields,
[fieldName]: {
...prevState.fields[fieldName],
error
}
}
}));
}, []);
// Touch field
const touchField = useCallback(<K extends keyof T>(fieldName: K) => {
setFormState(prevState => {
const field = prevState.fields[fieldName];
const error = validateOnBlur
? validateField(fieldName, field.value, field.rules)
: field.error;
return {
...prevState,
fields: {
...prevState.fields,
[fieldName]: {
...field,
touched: true,
error
}
}
};
});
}, [validateField, validateOnBlur]);
// Validate all fields
const validateForm = useCallback(() => {
setFormState(prevState => {
const newFields = { ...prevState.fields };
let isValid = true;
for (const key in newFields) {
const field = newFields[key];
const error = validateField(key, field.value, field.rules);
newFields[key] = {
...field,
error,
touched: true
};
if (error) {
isValid = false;
}
}
return {
...prevState,
fields: newFields,
isValid
};
});
return formState.isValid;
}, [validateField, formState.isValid]);
// Reset form
const resetForm = useCallback(() => {
setFormState(prevState => {
const fields = {} as { [K in keyof T]: FormField<T[K]> };
for (const key in initialValues) {
fields[key] = {
value: initialValues[key],
error: undefined,
touched: false,
rules: validationRules[key] || []
};
}
return {
fields,
isValid: true,
isSubmitting: false,
isDirty: false
};
});
}, [initialValues, validationRules]);
// Submit form
const handleSubmit = useCallback(async (e?: React.FormEvent) => {
e?.preventDefault();
// Validate all fields
const isValid = validateForm();
if (!isValid) {
return;
}
setFormState(prevState => ({ ...prevState, isSubmitting: true }));
try {
// Extract values from form state
const values = {} as T;
for (const key in formState.fields) {
values[key] = formState.fields[key].value;
}
await onSubmit(values);
} catch (error) {
console.error('Form submission error:', error);
} finally {
setFormState(prevState => ({ ...prevState, isSubmitting: false }));
}
}, [validateForm, formState.fields, onSubmit]);
// Get field props for input elements
const getFieldProps = useCallback(<K extends keyof T>(fieldName: K) => {
const field = formState.fields[fieldName];
return {
value: field.value,
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setFieldValue(fieldName, e.target.value as T[K]);
},
onBlur: () => touchField(fieldName),
error: field.error,
touched: field.touched,
hasError: field.touched && !!field.error
};
}, [formState.fields, setFieldValue, touchField]);
// Get form values
const getValues = useCallback(() => {
const values = {} as T;
for (const key in formState.fields) {
values[key] = formState.fields[key].value;
}
return values;
}, [formState.fields]);
// Set form values
const setValues = useCallback((values: Partial<T>) => {
setFormState(prevState => {
const newFields = { ...prevState.fields };
for (const key in values) {
if (key in newFields) {
const field = newFields[key];
const error = validateOnChange
? validateField(key, values[key]!, field.rules)
: field.error;
newFields[key] = {
...field,
value: values[key]!,
error,
touched: true
};
}
}
// Recalculate form validity
const isValid = Object.values(newFields).every(field => !field.error);
const isDirty = Object.keys(newFields).some(key =>
newFields[key as keyof T].value !== initialValues[key as keyof T]
);
return {
...prevState,
fields: newFields,
isValid,
isDirty
};
});
}, [validateField, validateOnChange, initialValues]);
return {
// Form state
isValid: formState.isValid,
isSubmitting: formState.isSubmitting,
isDirty: formState.isDirty,
// Field methods
getFieldProps,
setFieldValue,
setFieldError,
touchField,
// Form methods
validateForm,
resetForm,
handleSubmit,
getValues,
setValues,
// Raw form state (for advanced usage)
formState
};
}
// Usage example
interface UserFormData {
name: string;
email: string;
age: number;
password: string;
confirmPassword: string;
terms: boolean;
}
function UserRegistrationForm() {
const form = useForm<UserFormData>({
initialValues: {
name: '',
email: '',
age: 0,
password: '',
confirmPassword: '',
terms: false
},
validationRules: {
name: [
{ required: true, message: 'Name is required' },
{ minLength: 2, message: 'Name must be at least 2 characters' },
{ maxLength: 50, message: 'Name must be no more than 50 characters' }
],
email: [
{ required: true, message: 'Email is required' },
{
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Please enter a valid email address'
}
],
age: [
{ required: true, message: 'Age is required' },
{
custom: (value) => {
if (value < 18) return 'You must be at least 18 years old';
if (value > 120) return 'Please enter a valid age';
return undefined;
}
}
],
password: [
{ required: true, message: 'Password is required' },
{ minLength: 8, message: 'Password must be at least 8 characters' },
{
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: 'Password must contain at least one lowercase letter, one uppercase letter, and one number'
}
],
confirmPassword: [
{ required: true, message: 'Please confirm your password' },
{
custom: (value) => {
if (value !== form.getValues().password) {
return 'Passwords do not match';
}
return undefined;
}
}
],
terms: [
{
custom: (value) => {
if (!value) return 'You must accept the terms and conditions';
return undefined;
}
}
]
},
onSubmit: async (values) => {
console.log('Submitting form:', values);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
alert('Registration successful!');
form.resetForm();
}
});
const nameProps = form.getFieldProps('name');
const emailProps = form.getFieldProps('email');
const ageProps = form.getFieldProps('age');
const passwordProps = form.getFieldProps('password');
const confirmPasswordProps = form.getFieldProps('confirmPassword');
const termsProps = form.getFieldProps('terms');
return (
<form onSubmit={form.handleSubmit} className="user-form">
<h2>User Registration</h2>
<div className="form-group">
<label htmlFor="name">Name</label>
<input
id="name"
type="text"
{...nameProps}
className={nameProps.hasError ? 'error' : ''}
/>
{nameProps.hasError && (
<span className="error-message">{nameProps.error}</span>
)}
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
{...emailProps}
className={emailProps.hasError ? 'error' : ''}
/>
{emailProps.hasError && (
<span className="error-message">{emailProps.error}</span>
)}
</div>
<div className="form-group">
<label htmlFor="age">Age</label>
<input
id="age"
type="number"
{...ageProps}
className={ageProps.hasError ? 'error' : ''}
/>
{ageProps.hasError && (
<span className="error-message">{ageProps.error}</span>
)}
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
{...passwordProps}
className={passwordProps.hasError ? 'error' : ''}
/>
{passwordProps.hasError && (
<span className="error-message">{passwordProps.error}</span>
)}
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type="password"
{...confirmPasswordProps}
className={confirmPasswordProps.hasError ? 'error' : ''}
/>
{confirmPasswordProps.hasError && (
<span className="error-message">{confirmPasswordProps.error}</span>
)}
</div>
<div className="form-group">
<label>
<input
type="checkbox"
checked={termsProps.value}
onChange={(e) => form.setFieldValue('terms', e.target.checked)}
onBlur={() => form.touchField('terms')}
/>
I accept the terms and conditions
</label>
{termsProps.hasError && (
<span className="error-message">{termsProps.error}</span>
)}
</div>
<div className="form-actions">
<button
type="button"
onClick={form.resetForm}
disabled={form.isSubmitting}
>
Reset
</button>
<button
type="submit"
disabled={!form.isValid || form.isSubmitting}
>
{form.isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</div>
<div className="form-status">
<p>Form is {form.isValid ? 'valid' : 'invalid'}</p>
<p>Form is {form.isDirty ? 'dirty' : 'clean'}</p>
</div>
</form>
);
}
Additional Custom Hooks Examples:
- useLocalStorage Hook:
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValue = useCallback((value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue]);
return [storedValue, setValue] as const;
}
- useDebounce Hook:
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
- useAsync Hook:
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useAsync<T>(asyncFunction: () => Promise<T>, dependencies: any[] = []) {
const [state, setState] = useState<AsyncState<T>>({
data: null,
loading: false,
error: null
});
const execute = useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const data = await asyncFunction();
setState({ data, loading: false, error: null });
} catch (error) {
setState({
data: null,
loading: false,
error: error instanceof Error ? error : new Error('Unknown error')
});
}
}, dependencies);
useEffect(() => {
execute();
}, [execute]);
return { ...state, refetch: execute };
}
Best Practices for Custom Hooks:
- Always start with "use":
// ✅ Good
function useCounter() { /* ... */ }
function useLocalStorage() { /* ... */ }
// ❌ Bad
function counter() { /* ... */ }
function localStorage() { /* ... */ }
- Return consistent interfaces:
// ✅ Good - consistent return pattern
function useToggle(initialValue: boolean) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
return [value, toggle] as const;
}
- Handle cleanup properly:
function useEventListener(
eventName: string,
handler: (event: Event) => void,
element: Element | Window = window
) {
useEffect(() => {
element.addEventListener(eventName, handler);
return () => {
element.removeEventListener(eventName, handler);
};
}, [eventName, handler, element]);
}
- Use TypeScript for better type safety:
interface UseApiOptions<T> {
url: string;
initialData?: T;
dependencies?: any[];
}
function useApi<T>({ url, initialData, dependencies = [] }: UseApiOptions<T>) {
// Implementation with proper typing
}
Custom hooks are powerful tools for code reuse and separation of concerns. They allow you to extract complex logic from components and make it reusable across your application while maintaining the benefits of React's hooks system.