13 min read

Building your first custom hook (form handling)

Building your first custom hook (form handling)

If you've been following along with our React series, you've learned about useStateuseEffect, 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:

  1. Starts with the word use (this is a convention and a rule in the React linting ecosystem)
  2. Calls other hooks (useStateuseEffectuseRef, etc.)
  3. 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 useState calls for each field
  • Lots of onChange handlers 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 handleChangehandleBlurhandleSubmit
  • Provide resetForm to 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 onSubmit with 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 resetForm after 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 validate function
  • Track errors
  • Track touched fields (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: In handleSubmit, we recompute validation explicitly rather than relying on existing errors state, 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 (standardexpress)
  • 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 useForm for all field and validation logic
  • Keeps App focused 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:

  • isSubmitting to disable the submit button while saving
  • submitError for server-side validation errors
  • setFieldValue / setFieldTouched helpers

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 initialValuesvalidate, 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/useEffect patterns

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:

  1. Keep refs for each input
  2. 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:

  • useRef for DOM elements
  • useEffect with dependencies ([errors]) to react when validation errors change

If you want to revisit these topics in more depth, you can look back at:


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 useRef and useEffect

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.