Forms And Validation In Ionic React

Forms And Validation In Ionic React

Ionic Framework is a UI Toolkit for building cross-platform mobile applications using HTML, CSS, and JavaScript. The release of Ionic 5 in early 2020 came with official support for React, enabling React developers to easily build mobile applications using their favorite tools. There isn’t much support for working with forms, however, and many of the existing libraries available for building forms in the React ecosystem do not play nicely with Ionic Framework’s components.

You will learn how to build forms using Ionic React’s UI input components in this tutorial. You will also learn how to use a library to help with detecting form input changes and responding to validation rules. Finally, you will learn to make your forms accessible to screen readers by adding helpful text to your inputs’ ARIA attributes.

Ionic’s Form Components

Forms are an important part of most web and mobile applications today. Whether you are enabling access to restricted parts of your application through user registration and login forms or collecting feedback from your users, you have to — at some point in your application’s lifecycle — build a form.

Ionic provides prebuilt components for working with forms — some of which include IonItem, IonLabel, IonInput, IonCheckbox and IonRadio. We can combine these components to build standard looking forms without adding any styling ourselves.

For example, the following code:

<form className="ion-padding">
  <IonItem>
    <IonLabel position="floating">Username</IonLabel>
    <IonInput />
  </IonItem>
  <IonItem>
    <IonLabel position="floating">Password</IonLabel>
    <IonInput type="password" />
  </IonItem>
  <IonItem lines="none">
    <IonLabel>Remember me</IonLabel>
    <IonCheckbox defaultChecked={true} slot="start" />
  </IonItem>
  <IonButton className="ion-margin-top" type="submit" expand="block">
    Login
  </IonButton>
</form>

Will give us a login form which looks like this:

Out of the box, Ionic's form components look great on iOS or Android, but they can be a bit unwieldy if you are working with React. As with most tools in the React ecosystem, you have to decide how you want to go about building your forms when it comes to functionality and accessibility — both equally as important as design.

While there are already so many React form helpers available to choose from, most of them do not work with Ionic's form components. I suspect the main reason for this is that the event fired when a field value changes in Ionic is onIonChange, whereas most of the existing form libraries listen for onChange.

React Hook Form: Small And Fast React Forms Library

Thankfully, it isn't all doom and gloom. I recently came across React Hook Form (RHF), a library for working with forms in React projects. It provides support for controlled or uncontrolled components and input validation, and the API is hooks-based so it only works with functional components.

The most appealing feature for Ionic React developers — in my opinion — is the wrapper <Controller /> component it provides for working with controlled components. The component has an onChangeName prop which can be used to specify the change event name for whatever component instance you pass to it. I’ll show you how this makes working with forms in Ionic really easy in the following sections.

Building A Signup Form

Let's see how RHF helps us with form functionality as we build a registration form in Ionic. If you are running the latest version of the Ionic CLI (run npm i -g @ionic/cli to confirm), start a new Ionic app with React by running the following command:

ionic start myApp blank --type=react

I used a blank template here. You should be able to rewrite your existing forms to use the React Hook Form library with ease, especially if your components are written as Functional Components.

Note: You should remove the ExploreContainer component and its import in Home.tsx before proceeding with this tutorial.

To get started with your form, install the React Hook Form package by running the following command in your project’s root directory:

yarn add react-hook-form

This will make the React Hook Form library available in your project. Let’s create a form input field using the library. Open the Home.tsx file and replace its contents with the following:

import { IonContent, IonPage, IonText, IonItem, IonLabel, IonInput, IonButton } from "@ionic/react";
import React from "react";
import "./Home.css";
import { Controller, useForm } from 'react-hook-form';

const Home: React.FC = () => {
  const { control, handleSubmit } = useForm();

  const registerUser = (data) => {
    console.log('creating a new user account with: ', data);
  }

  return (
    <IonPage>
      <IonContent className="ion-padding">
        <IonText color="muted">
          <h2>Create Account</h2>
        </IonText>
        <form onSubmit={handleSubmit(registerUser)}>
          <IonItem>
            <IonLabel position="floating">Email</IonLabel>
            <Controller
              as={<IonInput type="email" />}
              name="email"
              control={control}
              onChangeName="onIonChange"
            />
          </IonItem>
          <IonButton expand="block" type="submit" className="ion-margin-top">
            Register
          </IonButton>
        </form>
      </IonContent>
    </IonPage>
  );
};
export default Home;

This gives you a form with a single field to collect an email address. Let’s break down the important parts (highlighted in the code block).

First, we destructure the return value of the useForm() hook from RHF. handleSubmit passes your input’s values to the handler function you specify when the form passes validation. control is an object containing methods used for registering controlled components into RHF.

Next, we have a standard form item block, but unlike the example for the login form, we pass the IonInput component to RHF’s <Controller /> component, register the change event by setting <Controller />'s onChangeName prop to Ionic’s change event name, and set the control prop to the control object from invoking useForm().

This is good so far, but you might find yourself repeating nearly the same code over and over again. You could try to make a reusable Input component that builds an input field with given properties.

Create a file in the src/components directory named Input.tsx and add the following code to the file:

import React, { FC } from "react";
import { IonItem, IonLabel, IonInput } from "@ionic/react";
import { Controller, Control } from "react-hook-form";

export interface InputProps {
  name: string;
  control?: Control;
  label?: string;
  component?: JSX.Element;
}

const Input: FC<InputProps> = ({
  name,
  control,
  component,
  label,
}) => {
  return (
    <>
      <IonItem>
        {label && (
          <IonLabel position="floating">{label}</IonLabel>
        )}
        <Controller
          as={component ?? <IonInput />}
          name={name}
          control={control}
          onChangeName="onIonChange"
        />
      </IonItem>
    </>
  );
};

export default Input;

This component receives a name prop and optional control, component and label props and renders an input field using the Ionic form components introduced earlier. This reduces the amount of code you have to write when creating form input fields. You can finish the rest of your form using this component. Edit the Home.tsx file with the following changes:

import { IonContent, IonPage, IonText, IonInput, IonButton, IonCheckbox, IonItem, IonLabel } from "@ionic/react";
import React from "react";
import "./Home.css";
import { useForm } from "react-hook-form";
import Input, { InputProps } from "../components/Input";

const Home: React.FC = () => {
  const { control, handleSubmit } = useForm();

  const formFields: InputProps[] = [
    {
      name: "email",
      component: <IonInput type="email" />,
      label: "Email",
    },
    {
      name: "fullName",
      label: "Full Name",
    },
    {
      name: "password",
      component: <IonInput type="password" clearOnEdit={false} />,
      label: "Password",
    },
  ];

  const registerUser = (data) => {
    console.log("creating a new user account with: ", data);
  };

  return (
    <IonPage>
      <IonContent>
        <div className="ion-padding">
          <IonText color="muted">
            <h2>Create Account</h2>
          </IonText>
          <form onSubmit={handleSubmit(registerUser)}>
            {formFields.map((field, index) => (
              <Input {...field} control={control} key={index} />
            ))}
            <IonItem>
              <IonLabel>I agree to the terms of service</IonLabel>
              <IonCheckbox slot="start" />
            </IonItem>
            <IonButton expand="block" type="submit" className="ion-margin-top">
              Register
            </IonButton>
          </form>
        </div>
      </IonContent>
    </IonPage>
  );
};

export default Home;

With your setup so far, you have an array of your form’s input fields (name is the only required property), with each field rendered using the Input component from earlier. You can take this even further and have your field data in a JSON file, keeping the code within your components with forms clean. At this point, your app (running at http://localhost:8100 with the ionic serve command) should look like this:

How About Field Validation?

You might have noticed that our form’s input fields do not have any validation logic yet. If this were an app intended for real-world use, that could lead to many undesirable effects unless your API is set up to validate incoming data. By the way, your API must always validate incoming data.

RHF comes with validation which aligns with the HTML standard for form validation built-in. This works great for simple validation like making a field required or setting minimum and maximum field lengths. If you want to use complex validation logic, I would recommend using Yup. While you can use any object schema validation library, RHF supports Yup out of the box.

Run the following command to install the library (and typings):

yarn add yup @types/yup

Next, add this to your component’s imports:

import { object, string } from 'yup';

const Home: React.FC = () => { ... }

Then, add the following code at the top of your component:

const Home: React.FC = () => {
  const validationSchema = object().shape({
    email: string().required().email(),
    fullName: string().required().min(5).max(32),
    password: string().required().min(8),
  });
  // ...
}

Here, we’ve created an object schema and added validation rules to each property using yup. The names in the object must match with names in your form’s input tags otherwise your rules won’t be triggered.

Finally, update your useForm() hook to use the schema we’ve defined by setting the validationSchema property like this:

const { control, handleSubmit } = useForm({
  validationSchema,
});

Now, when you click on the submit button the handleSubmit handler isn’t invoked and the form data isn’t submitted. While this is exactly what we wanted, it looks like there is no way for the user to know what’s happening. Let’s fix this by showing text hints when a field isn’t filled correctly.

First, update the Input component to look like the following:

import React, { FC } from "react";
import { IonItem, IonLabel, IonInput, IonText } from "@ionic/react";
import { Controller, Control, NestDataObject, FieldError } from "react-hook-form";

export interface InputProps {
  name: string;
  control?: Control;
  label?: string;
  component?: JSX.Element;
  errors?: NestDataObject<Record<string, any>, FieldError>;
}

const Input: FC<InputProps> = ({
  name,
  control,
  component,
  label,
  errors,
}) => {
  return (
    <>
      <IonItem>
        {label && <IonLabel position="floating">{label}</IonLabel>}
        <Controller
          as={component ?? <IonInput />}
          name={name}
          control={control}
          onChangeName="onIonChange"
        />
      </IonItem>
      {errors && errors[name] && (
        <IonText color="danger" className="ion-padding-start">
          <small>{errors[name].message}</small>
        </IonText>
      )}
    </>
  );
};

export default Input;

Here, we’ve updated our component to receive an extra optional property which is the error object from RHF, and we display an error message in the returned input field whenever there is an error. One last thing, add the errors object to your destructured object and update the component in your loop:

const { control, handleSubmit, errors } = useForm({
  validationSchema,
});
  {formFields.map((field, index) => (
    <Input {...field} control={control} key={index} errors={errors} />
  ))}

Your forms now provide visual cues when a user isn’t doing something right. Yup allows you to change the error message. You can do this by passing a string to the validation method you’re using. For email, as an example, you can do the following:

{
  email: string()
    .email('Please provide a valid email address')
    .required('This is a required field'),
}

Improving Accessibility

Ionic’s components are usually wrappers over the corresponding native element, meaning that they accept most — if not all — of that element’s existing attributes. You can improve your input fields and make them more accessible to visually impaired users by setting ARIA attributes with relevant text.

To continue with our example registration form, open the Input.tsx file and make the following changes:

import React, { FC } from "react";
import { IonItem, IonLabel, IonInput, IonText } from "@ionic/react";
import { Controller, Control, NestDataObject, FieldError } from "react-hook-form";

export interface InputProps {
  name: string;
  control?: Control;
  label?: string;
  component?: JSX.Element;
  errors?: NestDataObject<Record<string, any>, FieldError>;
}

const Input: FC<InputProps> = ({
  name,
  control,
  component,
  label,
  errors,
}) => {
  return (
    <>
      <IonItem>
        {label && <IonLabel position="floating">{label}</IonLabel>}
        <Controller
          as={
            component ?? (
              <IonInput
                aria-invalid={errors && errors[name] ? "true" : "false"}
                aria-describedby={${name}Error}
              />
            )
          }
          name={name}
          control={control}
          onChangeName="onIonChange"
        />
      </IonItem>
      {errors && errors[name] && (
        <IonText color="danger" className="ion-padding-start">
          <small>
            <span role="alert" id={${name}Error}>
              {errors[name].message}
            </span>
          </small>
        </IonText>
      )}
    </>
  );
};

export default Input;

The default IonInput component we’re passing to Controller now includes an aria-invalid attribute to indicate whether the field has an error, and an aria-describedby attribute to point to the corresponding error message. The error message is now wrapped with a span having an ARIA role set to “error”. Now, when your field has an error, a screen reader will highlight that field and read out the error message.

  • You’ll find the GitHub repo over here.

Conclusion

Congratulations! You have learned how to build and validate forms when building cross-platform apps using Ionic. You’ve also seen how easy it is to make your input fields accessible to users with a visual impairment. Hopefully, this tutorial provides a solid platform that you can use when building forms in your Ionic React apps. There are other components for building forms (such as select and radios) that we didn’t explore in this tutorial, but you can find and read more about them in the official docs.

References

  • Ionic Framework Docs
  • React Hook Form
  • Yup Docs
  • Phil Haack on Validating Email Addresses
  • Accessibility on MDN Web Docs