DEV Community

Elias Júnior
Elias Júnior

Posted on • Edited on

Creating masked inputs for React Hook Form

Sometimes I needed custom input fields in my forms like prices, Brazilian CPF/CNPJ, phone numbers, and others. Yes, I use the Controlled field, but let me use the price as an example: How would you handle a field that for the user shows "$ 1.59", but when you submit the form, you want to get the floating value of 1.59 instead of a string?

The answer is simple, which I call creating a formatter. The formatter will follow this structure:

export type Formatter<T = string> = {
  format: (value: T) => string;
  parse: (value: string) => T;
};
Enter fullscreen mode Exit fullscreen mode

That means we are going to have an object with two methods:

  • format: which will take the raw value and format into a string
  • parse: which will take the string and parse again into a string

So a default formatter will be output and parse the same value:

export const defaultFormatter: Formatter = {
  format: (value) => value,
  parse: (value) => value,
};
Enter fullscreen mode Exit fullscreen mode

But, now let's go to the price formatter that I used as an example. I will call it currencyFormatter.

export const currencyFormatter: (config?: {
  locale?: string;
  currency?: string;
}) => Formatter<number> = ({ locale = "pt-BR", currency = "BRL" } = {}) => {
  const numberFormatter = new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
  });

  return {
    format: (value: number) => {
      return numberFormatter.format(value);
    },
    parse: (value: string) => {
      const rawValue = parseInt(value.replace(/\D/g, ""), 10) || 0;

      return rawValue / 100;
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

The format method is pretty simple: it will receive a value that is a number and should format using Intl.NumberFormat to the defined currency.

The parse instead will create a regex to extract all numbers in the string (eg: R$ 100,01 = 10001), and split by 100 to get the cents (maybe you want the integer value to handle payments without the floating value).

With this simple formatter, you already have a way to format and parse values, that you'll need to handle the input. Now you just need to create your ControlledInput component to do what you want:

import { ChangeEventHandler, FocusEventHandler, forwardRef } from "react";

import { useController } from "react-hook-form";
import { mergeRefs } from "react-merge-refs";

import { Formatter, defaultFormatter } from "./formatters";

type ControlledInputProps = {
  name: string;
  formatter?: Formatter<any>;
} & InputProps;

export const ControlledInput = forwardRef<HTMLInputElement, ControlledInputProps>(
  ({ name, formatter = defaultFormatter, ...props }, ref) => {
    const { field } = useController({
      name,
      defaultValue: props.value,
    });

    const inputRef = mergeRefs([ref, field.ref]);

    const onChange: ChangeEventHandler<HTMLInputElement> = (event) => {
      field.onChange(formatter.parse(event.target.value));
      props.onChange?.(event);
    };

    const onBlur: FocusEventHandler<HTMLInputElement> = (event) => {
      field.onBlur();
      props.onBlur?.(event);
    };

    const inputValue = field.value ? formatter.format(field.value ?? "") : "";

    return (
      <input
        {...props}
        name={name}
        ref={inputRef}
        onChange={onChange}
        onBlur={onBlur}
        value={inputValue}
      />
    );
  },
);
Enter fullscreen mode Exit fullscreen mode

Notes:

  • I'm using react-merge-refs lib to keep the ref available for the input fields with React's forwardRef.
  • {...props} needs to be on the beginning or it will override the onChange and onBlur behavior.

Now you can wrap your form with FormProvider according to the documentation and insert your custom field like this:

<FormInput name="price" formatter={currencyFormatter()} />
Enter fullscreen mode Exit fullscreen mode

Be free to optimize the code, as it's just a starter example.

Top comments (0)