DEV Community

Cover image for How to Create Reusable Logic with Custom React Hooks
Cheryl Mataitini
Cheryl Mataitini

Posted on

How to Create Reusable Logic with Custom React Hooks

What are React Hooks?

React’s built-in Hooks like useState and useEffect make it possible to manage state and lifecycle features directly in functional components. But when we need reusable logic across components, custom Hooks provide a way to keep our code organized and scalable.

Why are Hooks useful and when to use them

Hooks encapsulate reusable logic, making it easier to manage state and lifecycle features across components.

Custom Hooks are especially useful for handling forms, data fetching, and shared state, reducing redundancy and making our codebase scalable and maintainable.

What makes a function a ‘Hook’ in React?

A function is considered a Hook in React if it encapsulates reusable stateful logic and follows the naming convention by being prefixed with “use”.

The “use” prefix signals to React that it’s a Hook, allowing it to apply special internal rules (covered later) to manage the function’s behavior.

EXAMPLE

Source code available here on GitHub.

Our example app consists of forms that capture user information. It has two sections: ‘Personal Details’ and ‘Payment Details. Each section captures data and requires validation. Here’s how custom hooks simplify form handling and validation.

This is the structure of the app, we’ll be walking through these files specifically: PersonalDetailsForm.jsx for defining form fields, useForm.js custom Hook for managing state and submissions, and useValidation.js custom Hook for centralized validation logic.
React app file structure tree

PersonalDetailsForm.jsx – Using useForm and validation schema

This is the component file for the ‘Personal Details’ form.

import { Input, Dropdown, Button } from "@/components";
import useForm from "@/hooks/useForm";
import { validations } from "@/util/validationHelper";

const PersonalDetailsForm = ({ title }) => {
  const initialData = {
    FirstName: "",
    LastName: "",
    Email: "",
    ReceiveMarketing: false
  };

  // Validations
  const validationSchema = {
    FirstName: [
      {
        validator: validations.required,
        message: "First name is required"
      },
      {
        validator: (value) => validations.minLength(value, 2),
        message: "First name must be at least two characters"
      }
    ],
    LastName: [
      {
        validator: validations.required,
        message: "Last name is required"
      }
    ],
    Email: [
      {
        validator: validations.required,
        message: "Email is required"
      },
      { validator: validations.email, message: "Invalid email" }
    ]
  };

  // Custom Hook useForm for state management and validation handling
  const { formData, handleInputChange, handleSubmit, validationProps } =
    useForm(initialData, () => console.log(formData), validationSchema);

  return (
    <>
      <div className="bg-white rounded-md p-6">
        <form onSubmit={handleSubmit}>
          <h2 className="text-2xl font-semibold text-orange-600 pb-4">
            {title}
          </h2>
          <Input
            label="First Name"
            placeholder="John"
            name="FirstName"
            onChange={handleInputChange}
            value={formData.FirstName}
            {...validationProps("FirstName")}
          />
          <Input
            label="Last Name"
            placeholder="Doe"
            name="LastName"
            onChange={handleInputChange}
            value={formData.LastName}
            {...validationProps("LastName")}
          />
          <Input
            label="Email"
            placeholder="Email"
            name="Email"
            onChange={handleInputChange}
            value={formData.Email}
            {...validationProps("Email")}
          />

          <Dropdown
            label="Would you like to receive marketing emails from us?"
            name="ReceiveMarketing"
            onChange={handleInputChange}
            value={formData.ReceiveMarketing}
            items={[
              { label: "Yes", value: true },
              { label: "No", value: false }
            ]}
            isRequired={false}
            isValid={true}
          />

          <Button type="submit" text="Submit" />
        </form>
      </div>
    </>
  );
};

export default PersonalDetailsForm;
Enter fullscreen mode Exit fullscreen mode

In PersonalDetailsForm.jsx, we initialize initialData to define field values and use validationSchema to specify validation rules. These are passed to the custom Hook useForm, which provides functions like handleInputChange for updating form state and validationProps for error handling and displaying error messages to the user.

useForm.js Custom Hook – Handling Form State and Submission

The custom useForm Hook manages form state with formData and handles submission. Form field changes are handled with handleInputChange. This function parses different data types (like text, checkboxes and numbers) and validates each field on change, keeping our PersonaDetailsForm component clean.

import { useState } from "react";
import useValidation from "@/hooks/useValidation";

const useForm = (initialValues, onSubmitCallback, validationSchema = {}) => {
  // Set form data state
  const [formData, setFormData] = useState(initialValues);

  // Calling another custom Hook - useValidation
  const { validateFieldOnChange, validateAllFields, validationProps } =
    useValidation(formData, validationSchema);

  const handleInputChange = (e) => {
    const { name, type, value, checked } = e.target;

    const parsedValue =
      type === "checkbox"
        ? checked
        : value === "true" // Convert string representation of boolean to boolean
        ? true
        : value === "false"
        ? false
        : type === "number"
        ? value === ""
          ? ""
          : parseFloat(value) // Parse as float if it's a number, allow empty string (for when user clears input)
        : value; // e.target.value

    setFormData({
      ...formData,
      [name]: parsedValue
    });

    validateFieldOnChange(name, value);
  };

  const resetForm = () => {
    const resetValues = Object.keys(formData).reduce((acc, key) => {
      acc[key] = "";
      return acc;
    }, {});

    setFormData(resetValues);
  };

  const handleSubmit = async (e) => {
    e.preventDefault();

    if (validateAllFields()) {
      try {
        await onSubmitCallback(formData);
      } catch (error) {
        console.error("Error during form submission", error);
      }
    } else {
      console.warn("Validation Failed");
      alert("Invalid Form");
    }
  };

  return {
    formData,
    handleInputChange,
    resetForm,
    handleSubmit,
    validationProps
  };
};

export default useForm;
Enter fullscreen mode Exit fullscreen mode

useValidation.js Custom Hook - Centralised Validation Logic

The useValidation custom Hook centralizes validation logic, with functions like validateFieldOnChange to check fields in real-time and validateAllFields to confirm all fields meet requirements before submission.

Using validationProps, we can easily attach validation feedback to each field. Look at the input components in PersonalDetailsForm.jsx to see how the validation props are spread.

import { useState, useMemo } from "react";

const useValidation = (formData, validationSchema) => {
  const [errors, setErrors] = useState({});

  const memoValidationSchema = useMemo(
    () => validationSchema,
    [validationSchema]
  );

  // Validate field on change
  const validateFieldOnChange = (name, value) => {
    if (memoValidationSchema[name]) {
      const fieldErrors = validateField(value, memoValidationSchema[name]);
      setErrors((prevErrors) => ({
        ...prevErrors,
        [name]: fieldErrors
      }));
    }
  };

  // Validate field against validation schema
  const validateField = (value, rules) => {
    let errors = [];

    for (const rule of rules) {
      if (
        typeof rule === "object" &&
        rule.validator &&
        !rule.validator(value)
      ) {
        errors.push(rule.message); // Add error message if validation fails
      }
    }

    return errors;
  };

  // Validate all fields against their validation schema
  const validateAllFields = () => {
    const newErrors = [];
    for (const field in memoValidationSchema) {
      let fieldErrors = validateField(
        formData[field],
        memoValidationSchema[field]
      );
      if (fieldErrors.length > 0) {
        newErrors[field] = fieldErrors;
      }
    }
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  // Validation props to use in form field component for error handling
  const validationProps = useMemo(
    () => (name) => ({
      isValid: !errors?.[name] || errors?.[name].length === 0,
      errorMessage: errors?.[name]?.[0]
    }),
    [errors]
  );

  return {
    validateFieldOnChange,
    validateAllFields,
    validationProps
  };
};

export default useValidation;
Enter fullscreen mode Exit fullscreen mode

PaymentDetailsForm.jsx – Reusability

This is the component file for the ‘Payment Details’ form.

Our setup allows us to reuse useForm and useValidation in any form component such as PaymentDetailsForm.jsx without duplicating logic.

The alternative?
Without custom Hooks, each form would need its own state management and validation logic, leading to repetitive and cluttered code. Custom Hooks centralize this functionality, providing a reusable approach that follows the DRY principle.

Rules of Hooks

To ensure that Hooks work reliably, React enforces a few key rules to maintain consistent behaviour across components.

  • Always call Hooks at the top level of your React function: Don’t use Hooks inside loops, conditions, nested functions, or after conditional returns. This ensures that Hooks are called in the same order on every render, which React relies on for tracking state correctly.
  • Don’t call Hooks in event handlers or class components: Hooks are specifically designed for use in functional components or within custom Hooks, using them in other places may lead to unexpected behaviour.
  • Use Hooks only within React functions or custom Hooks: Custom Hooks can call other Hooks, following the same rules, as shown in our example where the useForm Hook calls the useValidation Hook.

Reusability and maintainability are essential for building scalable applications. Custom Hooks in React allow us to achieve this by encapsulating logic that can be shared across components. This leads to cleaner, more modular code that’s easier to understand, test, and extend.

Top comments (2)

Collapse
 
asmyshlyaev177 profile image
Alex • Edited

Simple but good hook for close modals when clicking outside of them.


import { RefObject, useEffect } from 'react';

type Event = MouseEvent | TouchEvent;
type Handler = (event: Event) => void;

function useClickOutside(ref: RefObject<any>, handler: Handler) {
  useEffect(() => {
    const listener = (event: Event) => {
      if (!ref.current || ref.current.contains(event.target)) {
        return false;
      }
      return handler(event);
    };

    document.addEventListener('mousedown', listener);
    document.addEventListener('mouseup', listener);
    document.addEventListener('touchstart', listener);
    document.removeEventListener('touchend', listener);

    return () => {
      document.removeEventListener('mousedown', listener);
      document.addEventListener('mouseup', listener);
      document.removeEventListener('touchstart', listener);
      document.removeEventListener('touchend', listener);
    };
  }, [ref, handler]);
}
Enter fullscreen mode Exit fullscreen mode

gist.github.com/asmyshlyaev177/609...

This is version with Typescript and adjusted to work on mobile devices.

Good practice is to move to custom hooks repeating code.

Collapse
 
chewryl profile image
Cheryl Mataitini

Thanks for sharing this! That's a solid example for how custom hooks help with reusable code