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-refs
lib to keep theref
available for the input fields with React'sforwardRef
. -
{...props}
needs to be on the beginning or it will override theonChange
andonBlur
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()} />
Be free to optimize the code, as it's just a starter example.
Top comments (0)