Creating masked inputs for React Hook Form
Tags: react, form, validation, javascript
Published: 2024-01-23 22:31:24

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:

```ts
export type Formatter<T = string> = {
  format: (value: T) => string;
  parse: (value: string) => T;
};
```

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:

```ts
export const defaultFormatter: Formatter = {
  format: (value) => value,
  parse: (value) => value,
};
```

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

```ts
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;
    },
  };
};
```

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:

```tsx
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}
      />
    );
  },
);
```

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](https://react-hook-form.com/docs/formprovider) according to the documentation and insert your custom field like this:

```tsx
<FormInput name="price" formatter={currencyFormatter()} />
```

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