React Native self-validating input

Sooner or later, you get sick of validating stuff manually.

In order to be more efficient and avoid repetitive work, but still stay flexible we asked ourselves how do we structure a self-validating and reusable input component that returns both the input value and validation result to its parent?

There are a couple of marks to check:

  • component must be reusable
  • It should handle validation, label, error messages and everything else related to a single input element
  • easily extendable (to handle new requirements)
  • for now, it should handle only simple text input component
  • support for email, password, and other simpler types (name, address, notices, etc.)

While not exhaustive, this component should address validation and handling of basic data input for 90% of forms. For that special cases of weird phone numbers or special formatted discount code you can always write a custom component.

TL;DR we managed to replace more than 90% of our input fields with this component throughout the project.

Example

Say you have a screen where you have to enter an email address and name and validate both.

Something like this:

(...)
interface ParentComponentState {
email: string;
emailValid: boolean;
name: string;
nameValid: boolean;
// specify all state prop types
[prop: string]: string | boolean | undefined;
}
export class ParentComponent extends React.Component<
{},
ParentComponentState
> {
state = {
email: '',
name: '',
emailValid: false,
nameValid: false
};

handleSubmit = () => {
const { email, name, emailValid, nameValid } = this.state;
const formValid = emailValid && nameValid;
if (formValid){
// Do something with name & email
}
}

render() {
return (
<ScrollView contentContainerStyle={theme.scrollView}>
<Button title="Submit" onPress={this.handleSubmit}/>
</ScrollView>
);
}
}
(...)

As said above, we wanted to avoid writing handlers for each specific input type. After all, you do only need two handlers — to set the value and validation state and one for submitting everything.

(...)
handleSetValue = (prop: any, value: string, isValid: boolean) => {
this.setState({
[prop]: value,
[`${prop}Valid`]: isValid
});
};

handleSubmit = () => {
const { email, name, emailValid, nameValid } = this.state;
const formValid = emailValid && nameValid;
if (formValid) {
// Do something with name & email
}
};
(...)

These two methods, together with invoking the actual component and passing neccessary props (shown below) are everything that’s required in the parent component.

<ValidatedInput
value={name}
propName="name"
placeholder="Your name"
label="Your name"
editable={!somethingIsHappening}
changeCallback={this.handleSetValue}
/>
<ValidatedInput
value={email}
propName="email"
placeholder="Your email"
label="Your email"
editable={!somethingIsHappening}
changeCallback={this.handleSetValue}
validationType={ValidationMethod.Email}
/>

Now, the input component. You will have to replace styles with your own in the code.

(...)
interface ValidatedInputProps extends TextInputProps {
value: string;
propName: string;
label?: string;
validationType?: ValidationMethod;
minLength?: number;
optional?: boolean;
exists?: boolean;
compareValue?: string;
emptyErrorText?: string;
invalidErrorText?: string;
changeCallback: (prop: any, value: string, isValid: boolean) => void;
}
interface ValidatedInputState {
isValid: boolean;
isEmpty: boolean;
focused: boolean;
}
export enum ValidationMethod {
Email = 'EMAIL',
Date = 'DATE',
Password = 'PASSWORD',
Comparable = 'COMPARABLE'
}
export class ValidatedInput extends React.Component<
ValidatedInputProps,
ValidatedInputState
> {
state = {
isValid: true,
isEmpty: false,
focused: false
};
componentDidUpdate(prevProps: ValidatedInputProps) {
const { exists, value } = this.props;
if (prevProps.exists !== exists) {
this.validateByType(value);
}
}
getEmptyErrorText = () => {
const { label, emptyErrorText } = this.props;
return emptyErrorText
? emptyErrorText
: label
? `${label} is required.`
: `This field is required.`;
};
getInvalidErrorText = () => {
const { label, invalidErrorText } = this.props;
return invalidErrorText
? invalidErrorText
: label
? `${label} is invalid.`
: 'Value is invalid';
};
handleSetValue = (event: any) => {
const value = event.nativeEvent.text.trim();
this.validateByType(value);
};
validateByType = (currentValue: string) => {
const {
maxLength,
validationType,
optional,
compareValue,
changeCallback,
exists,
propName
} = this.props;

const defaultMinLength = optional ? 0 : 1;
let { minLength } = this.props;
minLength = minLength || defaultMinLength;

let isValid;
switch (validationType) {
case ValidationMethod.Email:
isValid = this.validateEmail(currentValue);
break;
case ValidationMethod.Password:
isValid = this.validatePassword(currentValue);
break;
case ValidationMethod.Comparable:
isValid = this.validateComparable(currentValue, compareValue!);
break;
default:
isValid = this.validateDefault(currentValue, minLength, maxLength);
break;
}
const isEmpty = optional ? false : !currentValue;
if ((isEmpty || exists) && !optional) {
isValid = false;
}
this.setState({
isEmpty,
isValid
});
changeCallback(propName, currentValue, isValid);
};
validateEmail = (value: string, exists?: boolean) =>
validation.EMAIL_REGEX.test(value);
validatePassword = (value: string) => {
return (
value.length >= validation.PASSWORD_LENGTH_MIN &&
value.length <= validation.PASSWORD_LENGTH_MAX
);
};
validateComparable = (value: string, compareValue: string) =>
value === compareValue;
validateDefault = (value: string, minLength: number, maxLength?: number) => {
return maxLength
? value.length >= minLength && value.length <= maxLength
: value.length >= minLength;
};
handleFocusEvent = () => {
this.setState({
focused: true
});
};
removeFocusState = () => {
this.setState({
focused: false
});
};
render() {
const { isValid, isEmpty, focused } = this.state;
const { label, value } = this.props;

return (
<View>
{label && (
<Text
// style={ focused ? FOCUSED_LABEL_STYLE : undefined }>
{label.toUpperCase()}
</Text>
)}
<TextInput
// style={focused ? FOCUSED_INPUT_STYLE : DEFAULT_INPUT_STYLE}
value={value}
onFocus={this.handleFocusEvent}
onBlur={this.removeFocusState}
onEndEditing={this.handleSetValue}
{...this.props}
/>
{isEmpty && (
<Text>
{this.getEmptyErrorText()}
</Text>
)}
{!isValid &&
!isEmpty && (
<Text>
{this.getInvalidErrorText()}
</Text>
)}
</View>
);
}
}
(...)

As you can see, the component props interface extends the default TextInput props, which means you can pass any default TextInput prop through the component and it will be applied internally.

The value change is triggered in the onEndEditing input event → every time the user submits / ends editing the value in the current input field. This will validate and update the input as soon as the user is done entering the content. This way the validation doesn’t trigger prematurely and doesn’t give validation errors while the user isn’t finished with editing the input value.

Using the changeCallback function, the value of the input and it’s validity are propagated back to the parent component.

Now, about the custom props:

  • propName = the parent component prop name you update with the input.
  • validationType = what kind of validation type is necessary
  • changeCallback = the handler the component passes it’s internal value to
  • optional = if this is set to true, input validation is disabled. Default value = false
  • editable = “false” disables editing the value if an action is performed such as submitting data to an API
  • exists = for passing let’s say, a userExists property in case you’re checking if an e-mail is already in use. Use with e-mail validationType only.
  • minLength = minimum character length
  • compareValue = used exclusively when validationType is set to ValidationMethod.Comparable. Use to pass value to compare the input value against.
  • value = the input value passed from the parent component.
  • label = display a custom label above the input. Leaving this empty hides the label.
  • emptyErrorText = custom error text when the value is empty
  • invalidErrorText = custom error text when the value is invalid

And there you have it. You can include the ValidatedInput component anywhere and it’ll self validate every time based on the passed properties. Of course, there is room for improvement, but it’s extendable and simple to use.

Best of all, at ~250 LOC for a whole ValidatedInput component, it is a whole lot less than libraries such as informed (which can handle a lot more than this though). Still, we consider this a win.


Interested in working with us? Drop us a line at hello@prototyp.digital. We are always interested in new projects and people.

Post authors: Sebastijan Dumančić and Luka Buljan