Building your first custom hook (form handling)
If you've been following along with our React series, you've learned about useState, useEffect, and useRef—powerful tools for managing component state and side effects. But here's where React truly shines: custom hooks. They're the secret weapon that transforms you from a React developer into a React architect.
Custom hooks let you extract repetitive logic from your components and reuse it across your entire application. In the real world of enterprise applications—whether you're managing order forms in an order management system or complex user workflows—you'll notice patterns. The same validation logic, the same form reset behavior, the same field synchronization appears again and again. Instead of copy-pasting code, custom hooks let you write it once, maintain it once, and deploy it everywhere.
In this article, we'll build a practical useForm custom hook that handles form state, validation, and submission—all the messy logic that clutters components. By the end, you'll understand not just how to write custom hooks, but why they're essential for building scalable React applications.
Let's dive in.
What Are Custom Hooks?
Before we build, let's clarify what a custom hook actually is. A custom hook is simply a JavaScript function that:
- Starts with the word
use(this is a convention and a rule in the React linting ecosystem) - Calls other hooks (
useState,useEffect,useRef, etc.) - Returns values that the component can use
That's it. There's no magic. No special React API. Custom hooks are pure JavaScript functions that leverage existing React hooks to create reusable logic.
// This is a custom hook
function useForm(initialValues) {
const [values, setValues] = useState(initialValues);
// ... more logic here
return { values, setValues };
}
// This is NOT a custom hook (doesn't use React hooks)
function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}The key difference: custom hooks encapsulate state and side effects. They're not just utility functions. They're stateful logic containers.
1. Why Build a Custom Hook for Forms?
In a typical React app, forms tend to grow messy very quickly:
- Repeated
useStatecalls for each field - Lots of
onChangehandlers that look almost identical - Manual validation for each field
- Boilerplate for resetting forms, tracking errors, and submit state
Now imagine this in an order management system:
- Customer address forms
- Order creation forms
- Shipment update forms
- Return / refund forms
Most of these forms share the same patterns:
- Track field values
- Validate inputs
- Display errors
- Handle submit
- Reset state on successful submission
A custom hook lets you:
- Encapsulate this logic once
- Reuse it across different forms
- Keep your components focused on UI, not plumbing
That’s exactly what we’re going to build: a small but powerful useForm hook, tailored to order management–style forms.
2. Designing the useForm API
Before we write any code, let’s design the hook’s API.
What we want the hook to do
We’d like to use it like this in a component:
const {
values,
errors,
touched,
handleChange,
handleBlur,
handleSubmit,
resetForm,
} = useForm({
initialValues: {
customerName: '',
customerEmail: '',
orderAmount: '',
},
validate: (values) => {
const errors = {};
// Simple validation logic...
return errors;
},
onSubmit: (values) => {
// Handle form submit (e.g., send to API)
},
});Then in JSX:
<form onSubmit={handleSubmit}>
<input
name="customerName"
value={values.customerName}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.customerName && errors.customerName && (
<p>{errors.customerName}</p>
)}
{/* other fields... */}
</form>Responsibilities of useForm
Our useForm hook will:
- Manage values for each field
- Track touched fields (for showing validation after blur)
- Run validation when values change or on submit
- Provide
handleChange,handleBlur,handleSubmit - Provide
resetFormto reset to initial values
We’ll start simple and enhance step by step.
3. Minimal useForm Hook: Values and Submission
Let’s first build a minimal useForm that:
- Stores form values
- Handles change events
- Calls
onSubmitwith values
We’ll then expand it with validation and touched state.
Step 1: Directory structure
In a simple React app, you can structure like this:
textsrc/
hooks/
useForm.js
App.js
index.js
We’ll create useForm.js and use it in App.js.
useForm.js (minimal version)
// src/hooks/useForm.js
import { useState } from 'react';
export function useForm({ initialValues, onSubmit }) {
const [values, setValues] = useState(initialValues || {});
// Generic change handler for all inputs
const handleChange = (event) => {
const { name, value, type, checked } = event.target;
setValues((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
const handleSubmit = (event) => {
if (event && event.preventDefault) {
event.preventDefault();
}
onSubmit(values);
};
const resetForm = () => {
setValues(initialValues || {});
};
return {
values,
handleChange,
handleSubmit,
resetForm,
};
}App.js example: Simple Order Creation Form
This example simulates creating a basic order: customer name, email, and order amount.
// src/App.js
import React, { useState } from 'react';
import { useForm } from './hooks/useForm';
function App() {
const [submittedOrder, setSubmittedOrder] = useState(null);
const {
values,
handleChange,
handleSubmit,
resetForm,
} = useForm({
initialValues: {
customerName: '',
customerEmail: '',
orderAmount: '',
isPriority: false,
},
onSubmit: (values) => {
// In a real OMS, you'd call an API here.
// For now, we'll just store it in state.
setSubmittedOrder({
...values,
orderId: `ORD-${Date.now()}`,
});
resetForm();
},
});
return (
<div style={{ maxWidth: 480, margin: '40px auto', fontFamily: 'sans-serif' }}>
<h1>Create Order</h1>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: 12 }}>
<label htmlFor="customerName">Customer Name</label>
<br />
<input
id="customerName"
name="customerName"
type="text"
value={values.customerName}
onChange={handleChange}
placeholder="e.g., John Doe"
style={{ width: '100%' }}
/>
</div>
<div style={{ marginBottom: 12 }}>
<label htmlFor="customerEmail">Customer Email</label>
<br />
<input
id="customerEmail"
name="customerEmail"
type="email"
value={values.customerEmail}
onChange={handleChange}
placeholder="e.g., [email protected]"
style={{ width: '100%' }}
/>
</div>
<div style={{ marginBottom: 12 }}>
<label htmlFor="orderAmount">Order Amount (USD)</label>
<br />
<input
id="orderAmount"
name="orderAmount"
type="number"
value={values.orderAmount}
onChange={handleChange}
placeholder="e.g., 250"
style={{ width: '100%' }}
/>
</div>
<div style={{ marginBottom: 12 }}>
<label htmlFor="isPriority">
<input
id="isPriority"
name="isPriority"
type="checkbox"
checked={values.isPriority}
onChange={handleChange}
/>{' '}
Mark as priority order
</label>
</div>
<button type="submit">Create Order</button>
<button
type="button"
onClick={resetForm}
style={{ marginLeft: 8 }}
>
Reset
</button>
</form>
{submittedOrder && (
<div
style={{
marginTop: 24,
padding: 16,
border: '1px solid #ccc',
borderRadius: 4,
}}
>
<h2>Order Created</h2>
<p><strong>Order ID:</strong> {submittedOrder.orderId}</p>
<p><strong>Name:</strong> {submittedOrder.customerName}</p>
<p><strong>Email:</strong> {submittedOrder.customerEmail}</p>
<p><strong>Amount:</strong> ${submittedOrder.orderAmount}</p>
<p>
<strong>Priority:</strong>{' '}
{submittedOrder.isPriority ? 'Yes' : 'No'}
</p>
</div>
)}
</div>
);
}
export default App;For this minimal version, we:
- Handle all field updates with a single
handleChange - Submit the form via
handleSubmit - Use
resetFormafter a successful submit
Now let’s make this hook more production-friendly by adding validation and touched state.
4. Adding Validation and Touched State
In real order management forms, you can’t allow anything to be submitted:
- Customer name is required
- Email must be valid
- Order amount should be a positive number
We’ll extend our hook to:
- Accept a
validatefunction - Track
errors - Track
touchedfields (so we only show errors after the user interacts with a field)
Updated useForm.js with validation
// src/hooks/useForm.js
import { useState } from 'react';
export function useForm({ initialValues, onSubmit, validate }) {
const [values, setValues] = useState(initialValues || {});
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const runValidation = (valuesToValidate) => {
if (typeof validate === 'function') {
const validationErrors = validate(valuesToValidate);
setErrors(validationErrors || {});
}
};
const handleChange = (event) => {
const { name, value, type, checked } = event.target;
const newValues = {
...values,
[name]: type === 'checkbox' ? checked : value,
};
setValues(newValues);
runValidation(newValues);
};
const handleBlur = (event) => {
const { name } = event.target;
setTouched((prev) => ({
...prev,
[name]: true,
}));
};
const handleSubmit = (event) => {
if (event && event.preventDefault) {
event.preventDefault();
}
// Mark all fields as touched on submit
const allTouched = Object.keys(values).reduce((acc, key) => {
acc[key] = true;
return acc;
}, {});
setTouched(allTouched);
runValidation(values);
// If no errors, call onSubmit
const hasErrors =
errors && Object.values(errors).some((errorMsg) => errorMsg);
// Important: we need to use fresh validation result here
const validationErrors = validate ? validate(values) : {};
const hasValidationErrors =
validationErrors &&
Object.values(validationErrors).some((msg) => msg);
setErrors(validationErrors || {});
if (!hasValidationErrors) {
onSubmit(values);
}
};
const resetForm = () => {
setValues(initialValues || {});
setErrors({});
setTouched({});
};
return {
values,
errors,
touched,
handleChange,
handleBlur,
handleSubmit,
resetForm,
};
}Note: InhandleSubmit, we recompute validation explicitly rather than relying on existingerrorsstate, to avoid any stale state issues.
5. Order Management Example: Order Creation with Validation
Now let’s use the enhanced hook in a slightly more realistic scenario.
Requirements
We’ll build a Create Order form with:
customerName(required)customerEmail(required, simple email format check)orderAmount(required, must be > 0)shippingMethod(standard,express)isGift(checkbox)
If validation passes, we’ll display the created order below the form.
App.js – Create Order Form with useForm
// src/App.js
import React, { useState } from 'react';
import { useForm } from './hooks/useForm';
function App() {
const [createdOrder, setCreatedOrder] = useState(null);
const {
values,
errors,
touched,
handleChange,
handleBlur,
handleSubmit,
resetForm,
} = useForm({
initialValues: {
customerName: '',
customerEmail: '',
orderAmount: '',
shippingMethod: 'standard',
isGift: false,
},
validate: (values) => {
const errors = {};
if (!values.customerName.trim()) {
errors.customerName = 'Customer name is required.';
}
if (!values.customerEmail.trim()) {
errors.customerEmail = 'Customer email is required.';
} else if (!/^\S+@\S+\.\S+$/.test(values.customerEmail)) {
errors.customerEmail = 'Please enter a valid email address.';
}
if (values.orderAmount === '') {
errors.orderAmount = 'Order amount is required.';
} else if (Number(values.orderAmount) <= 0) {
errors.orderAmount = 'Order amount must be greater than 0.';
}
if (!['standard', 'express'].includes(values.shippingMethod)) {
errors.shippingMethod = 'Invalid shipping method.';
}
return errors;
},
onSubmit: (values) => {
const newOrder = {
orderId: `ORD-${Date.now()}`,
...values,
createdAt: new Date().toISOString(),
};
setCreatedOrder(newOrder);
resetForm();
},
});
const renderError = (fieldName) => {
if (!touched[fieldName]) return null;
if (!errors[fieldName]) return null;
return (
<div style={{ color: 'red', fontSize: 12 }}>
{errors[fieldName]}
</div>
);
};
return (
<div style={{ maxWidth: 520, margin: '40px auto', fontFamily: 'sans-serif' }}>
<h1>Create Customer Order</h1>
<p style={{ color: '#555', fontSize: 14 }}>
This example uses a custom <code>useForm</code> hook to manage form
state, validation, and submission.
</p>
<form onSubmit={handleSubmit} noValidate>
<div style={{ marginBottom: 12 }}>
<label htmlFor="customerName">Customer Name</label>
<br />
<input
id="customerName"
name="customerName"
type="text"
value={values.customerName}
onChange={handleChange}
onBlur={handleBlur}
placeholder="e.g., John Doe"
style={{ width: '100%' }}
/>
{renderError('customerName')}
</div>
<div style={{ marginBottom: 12 }}>
<label htmlFor="customerEmail">Customer Email</label>
<br />
<input
id="customerEmail"
name="customerEmail"
type="email"
value={values.customerEmail}
onChange={handleChange}
onBlur={handleBlur}
placeholder="e.g., [email protected]"
style={{ width: '100%' }}
/>
{renderError('customerEmail')}
</div>
<div style={{ marginBottom: 12 }}>
<label htmlFor="orderAmount">Order Amount (USD)</label>
<br />
<input
id="orderAmount"
name="orderAmount"
type="number"
value={values.orderAmount}
onChange={handleChange}
onBlur={handleBlur}
placeholder="e.g., 250"
style={{ width: '100%' }}
/>
{renderError('orderAmount')}
</div>
<div style={{ marginBottom: 12 }}>
<label htmlFor="shippingMethod">Shipping Method</label>
<br />
<select
id="shippingMethod"
name="shippingMethod"
value={values.shippingMethod}
onChange={handleChange}
onBlur={handleBlur}
style={{ width: '100%' }}
>
<option value="standard">Standard (3–5 days)</option>
<option value="express">Express (1–2 days)</option>
</select>
{renderError('shippingMethod')}
</div>
<div style={{ marginBottom: 12 }}>
<label htmlFor="isGift">
<input
id="isGift"
name="isGift"
type="checkbox"
checked={values.isGift}
onChange={handleChange}
onBlur={handleBlur}
/>{' '}
Mark as gift order
</label>
</div>
<button type="submit">Create Order</button>
<button
type="button"
onClick={resetForm}
style={{ marginLeft: 8 }}
>
Reset
</button>
</form>
{createdOrder && (
<div
style={{
marginTop: 24,
padding: 16,
border: '1px solid #ccc',
borderRadius: 4,
}}
>
<h2>Order Created</h2>
<p><strong>Order ID:</strong> {createdOrder.orderId}</p>
<p><strong>Created At:</strong> {createdOrder.createdAt}</p>
<p><strong>Customer:</strong> {createdOrder.customerName}</p>
<p><strong>Email:</strong> {createdOrder.customerEmail}</p>
<p><strong>Amount:</strong> ${createdOrder.orderAmount}</p>
<p><strong>Shipping:</strong> {createdOrder.shippingMethod}</p>
<p>
<strong>Gift:</strong> {createdOrder.isGift ? 'Yes' : 'No'}
</p>
</div>
)}
</div>
);
}
export default App;This example:
- Uses
useFormfor all field and validation logic - Keeps
Appfocused on rendering and business meaning - Feels like a real order management UI, not just a “todo list” demo
6. Improving the Hook: Reusability and Extensibility
Now that we’ve built a working custom hook, let’s talk about why this is better than sprinkling useState everywhere.
a) Single source of truth for form behavior
All form behavior (change, blur, validate, submit, reset) lives in one place: useForm.
If you want to improve behavior later (e.g., add async validation, track isSubmitting state), you do it once, not in every form.
For example, you might extend useForm with:
isSubmittingto disable the submit button while savingsubmitErrorfor server-side validation errorssetFieldValue/setFieldTouchedhelpers
The App component doesn’t need to know about these implementation details.
b) Different forms, same hook
You can reuse the same useForm hook in different scenarios:
- Create Order
- Update Shipment Details
- Customer Address Form
- Return / Refund Request
Each form supplies its own initialValues, validate, and onSubmit, but shares the same hook.
c) Aligning with React’s philosophy
Custom hooks let you share logic without changing component hierarchy. They’re the idiomatic way to reuse stateful behavior in React:
- No HOCs
- No render props
- No repetition of
useState/useEffectpatterns
Just a simple function that uses hooks internally and exposes a clean API.
7. Connecting with useEffect and useRef
You’ve previously seen:
Custom hooks become even more powerful when you combine them with those hooks.
Let’s look at how they can play together in a form scenario.
a) Auto-focus first invalid field with useRef
We may want to:
- When the user submits the form with errors
- Automatically focus the first invalid field
This is a good use case for useRef.
We’ll:
- Keep refs for each input
- After validation on submit, focus the first field that has an error
Updating App.js to use refs
// src/App.js
import React, { useRef, useEffect, useState } from 'react';
import { useForm } from './hooks/useForm';
function App() {
const [createdOrder, setCreatedOrder] = useState(null);
const customerNameRef = useRef(null);
const customerEmailRef = useRef(null);
const orderAmountRef = useRef(null);
const shippingMethodRef = useRef(null);
const {
values,
errors,
touched,
handleChange,
handleBlur,
handleSubmit,
resetForm,
} = useForm({
initialValues: {
customerName: '',
customerEmail: '',
orderAmount: '',
shippingMethod: 'standard',
isGift: false,
},
validate: (values) => {
const errors = {};
if (!values.customerName.trim()) {
errors.customerName = 'Customer name is required.';
}
if (!values.customerEmail.trim()) {
errors.customerEmail = 'Customer email is required.';
} else if (!/^\S+@\S+\.\S+$/.test(values.customerEmail)) {
errors.customerEmail = 'Please enter a valid email address.';
}
if (values.orderAmount === '') {
errors.orderAmount = 'Order amount is required.';
} else if (Number(values.orderAmount) <= 0) {
errors.orderAmount = 'Order amount must be greater than 0.';
}
if (!['standard', 'express'].includes(values.shippingMethod)) {
errors.shippingMethod = 'Invalid shipping method.';
}
return errors;
},
onSubmit: (values) => {
const newOrder = {
orderId: `ORD-${Date.now()}`,
...values,
createdAt: new Date().toISOString(),
};
setCreatedOrder(newOrder);
resetForm();
},
});
// Focus first invalid field when errors change
useEffect(() => {
if (errors.customerName && customerNameRef.current) {
customerNameRef.current.focus();
} else if (errors.customerEmail && customerEmailRef.current) {
customerEmailRef.current.focus();
} else if (errors.orderAmount && orderAmountRef.current) {
orderAmountRef.current.focus();
} else if (errors.shippingMethod && shippingMethodRef.current) {
shippingMethodRef.current.focus();
}
}, [errors]);
const renderError = (fieldName) => {
if (!touched[fieldName]) return null;
if (!errors[fieldName]) return null;
return (
<div style={{ color: 'red', fontSize: 12 }}>
{errors[fieldName]}
</div>
);
};
return (
<div style={{ maxWidth: 520, margin: '40px auto', fontFamily: 'sans-serif' }}>
<h1>Create Customer Order</h1>
<form onSubmit={handleSubmit} noValidate>
<div style={{ marginBottom: 12 }}>
<label htmlFor="customerName">Customer Name</label>
<br />
<input
ref={customerNameRef}
id="customerName"
name="customerName"
type="text"
value={values.customerName}
onChange={handleChange}
onBlur={handleBlur}
placeholder="e.g., John Doe"
style={{ width: '100%' }}
/>
{renderError('customerName')}
</div>
<div style={{ marginBottom: 12 }}>
<label htmlFor="customerEmail">Customer Email</label>
<br />
<input
ref={customerEmailRef}
id="customerEmail"
name="customerEmail"
type="email"
value={values.customerEmail}
onChange={handleChange}
onBlur={handleBlur}
placeholder="e.g., [email protected]"
style={{ width: '100%' }}
/>
{renderError('customerEmail')}
</div>
<div style={{ marginBottom: 12 }}>
<label htmlFor="orderAmount">Order Amount (USD)</label>
<br />
<input
ref={orderAmountRef}
id="orderAmount"
name="orderAmount"
type="number"
value={values.orderAmount}
onChange={handleChange}
onBlur={handleBlur}
placeholder="e.g., 250"
style={{ width: '100%' }}
/>
{renderError('orderAmount')}
</div>
<div style={{ marginBottom: 12 }}>
<label htmlFor="shippingMethod">Shipping Method</label>
<br />
<select
ref={shippingMethodRef}
id="shippingMethod"
name="shippingMethod"
value={values.shippingMethod}
onChange={handleChange}
onBlur={handleBlur}
style={{ width: '100%' }}
>
<option value="standard">Standard (3–5 days)</option>
<option value="express">Express (1–2 days)</option>
</select>
{renderError('shippingMethod')}
</div>
<div style={{ marginBottom: 12 }}>
<label htmlFor="isGift">
<input
id="isGift"
name="isGift"
type="checkbox"
checked={values.isGift}
onChange={handleChange}
onBlur={handleBlur}
/>{' '}
Mark as gift order
</label>
</div>
<button type="submit">Create Order</button>
<button
type="button"
onClick={resetForm}
style={{ marginLeft: 8 }}
>
Reset
</button>
</form>
{createdOrder && (
<div
style={{
marginTop: 24,
padding: 16,
border: '1px solid #ccc',
borderRadius: 4,
}}
>
<h2>Order Created</h2>
<p><strong>Order ID:</strong> {createdOrder.orderId}</p>
<p><strong>Created At:</strong> {createdOrder.createdAt}</p>
<p><strong>Customer:</strong> {createdOrder.customerName}</p>
<p><strong>Email:</strong> {createdOrder.customerEmail}</p>
<p><strong>Amount:</strong> ${createdOrder.orderAmount}</p>
<p><strong>Shipping:</strong> {createdOrder.shippingMethod}</p>
<p>
<strong>Gift:</strong> {createdOrder.isGift ? 'Yes' : 'No'}
</p>
</div>
)}
</div>
);
}
export default App;This example demonstrates:
useReffor DOM elementsuseEffectwith dependencies ([errors]) to react when validation errors change
If you want to revisit these topics in more depth, you can look back at:
useEffectwith dependencies and cleanup:
https://thedevlearnings.com/useeffect-with-dependencies-and-cleanup-2/useReffor DOM and mutable state:
https://thedevlearnings.com/useref-for-dom-and-mutable-state/
8. Summary: What You’ve Learned
In this article, you built your first custom hook for form handling in React, tailored to an order management domain:
- Defined a clear API for
useForm - Implemented value management with a single
handleChange - Added validation and touched state to show errors appropriately
- Created a realistic Create Order form using the hook
- Enhanced UX by focusing the first invalid field using
useRefanduseEffect
Most importantly, you’ve seen how to move from ad-hoc useState usage to a reusable custom hook that can be dropped into multiple forms across your application.
9. What’s Next: Local State vs Global State Decisions
So far, all the form state we’ve worked with is local to the component. In a real order management system, you’ll often need to decide:
- Should this state live inside the component?
- Should it be lifted up to a parent?
- Should it be moved to a global store (e.g., context, Redux, Zustand, etc.)?
In the next article, we’ll dive into:
“Local state vs global state decisions”
We’ll explore when to keep state as close to the component as possible, and when it makes sense to move it to shared/global state in a complex order management application.
Member discussion