Dealing with number inputs in React can be a pain, especially when you need to make sure they're the right size or have the right number of decimal points. Simple stuff might seem easy, but once you get into the nitty-gritty and try to achieve custom user experiences, the code can get messy fast.
A common approach that most of us use is to write the custom validation logic that restricts the user input in onChange
handler.
Something like this
function NumberInput({
value,
onChange,
min,
max,
}: {
value: number;
onChange: (val: number) => void;
min?: number;
max?: number;
}) {
const changeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = +e.target.value;
if (min != null && val < min) {
onChange(min);
return;
}
if (max != null && val > max) {
onChange(max);
return;
}
onChange(val);
};
return (
<input
type="number"
value={value}
onChange={changeHandler}
/>
);
}
This logic looks good at a first glance but if you have tried it already you know it comes with lot of unexpected and weird behaviours and not at all a good user experience !
Another approach is to use standard HTML <input type="number">
element with the inbuilt validations using attributes like min
, max
, maxLength
etc. However, it lacks the instant feedback and the input restrictions that we usually want to implement.
We end up Googling our way to Stack Overflow and find some... hackish solutions.
- Using
onKeyPress
and manually validate the input values. - Using
pattern
attribute and add Regex to validate number ( which doesn't work with thetype="number"
so we have to make ittype="text"
). - and what not ...
After a lot of trial and error, I finally figured out a better way to do this.
The Solution
We can make use of inbuilt HTML input validation with some custom javascript to build a perfect solution.
Here is the component
// Interface for props overriding the default value and onChange
// attribute to accept only numeric value
export interface NumberInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> {
onChange: (val: number) => void;
value: number;
}
function NumberInput({
value,
onChange,
min,
max,
step,
...props
}: NumberInputProps) {
// Internal input state to avoid weird behaviors with empty inputs
const [input, setInput] = React.useState(value?.toString() || "");
const changeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
// Using HTML input validity API to validation
if (
(max != null && e.target.validity.rangeOverflow) ||
(min != null && e.target.validity.rangeUnderflow) ||
(step != null && e.target.validity.stepMismatch)
)
return;
const val = +e.target.value;
setInput(e.target.value);
onChange(val);
};
// To ensure the external updates are reflected in the input element
React.useEffect(() => {
setInput(value.toString());
}, [value]);
return (
<Input
ref={ref}
type="number"
value={input}
onChange={changeHandler}
min={min}
max={max}
step={step}
{...props}
/>
);
}
With this approach we can utilise the inbuilt HTML validations and also restrict the invalid user input for numbers.
Check out live example and play around
Bonus
We can make this logic more reusable by extracting it out in a custom hook like this
export const useNumberInput = ({
value,
onChange,
min,
max,
step,
}: {
value: number;
onChange: (val: number) => void;
max?: number;
min?: number;
step?: number;
}): InputHTMLAttributes<HTMLInputElement> => {
const [input, setInput] = useState(value?.toString() || "");
const changeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
if (
(max != null && e.target.validity.rangeOverflow) ||
(min != null && e.target.validity.rangeUnderflow) ||
(step != null && e.target.validity.stepMismatch)
)
return;
const val = +e.target.value;
setInput(e.target.value);
onChange(val);
};
useEffect(() => {
setInput(value.toString());
}, [value]);
return {
type: "number",
value: input,
onChange: changeHandler,
min,
max,
step,
};
};
And use it in any component where required ( Obviously which has an input
element ).
export default function CustomInput() {
const [value, setValue] = useState(0);
const inputProps = useNumberInput({
value,
onChange: setValue,
min: 1,
max: 50,
});
return (
<div>
<button onClick={() => onChange(value + 1)}>
+
</button>
<button onClick={() => onChange(value - 1)}>
-
</button>
<input
{...inputProps}
{...otherProps}
/>
</div>
);
}
Feel free to add comments and suggest improvements !
Top comments (0)