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;
};
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,
};
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;
},
};
};
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}
/>
);
},
);
Notes:
- I'm using
react-merge-refslib to keep therefavailable for the input fields with React'sforwardRef. -
{...props}needs to be on the beginning or it will override theonChangeandonBlurbehavior.
Now you can wrap your form with FormProvider according to the documentation and insert your custom field like this:
<FormInput name="price" formatter={currencyFormatter()} />
Be free to optimize the code, as it's just a starter example.
Top comments (0)