Ionic React Forms: Working Around the Event Gap

Ionic Framework offers a solid set of prebuilt UI components for cross-platform mobile apps, and since Ionic 5, React developers have had official support. The components like IonItem, IonLabel, and IonInput combine into clean, native-looking forms without custom styling. However, a significant friction point remains: Ionic components fire onIonChange instead of the standard onChange event. Most React form libraries listen for the latter, leaving Ionic React developers without easy access to popular solutions.

React Hook Form (RHF) addresses this with its <Controller /> wrapper, which exposes an onChangeName prop to specify the correct change event for the wrapped component. Combined with a hooks-based API for functional components, this makes RHF a practical choice for Ionic React form handling.

Setting Up a Registration Form

To see this in action, start a new Ionic React project with a blank template using the CLI, then install RHF in the project root:

yarn add react-hook-form

After removing the default ExploreContainer component and its import from Home.tsx, you can build a simple email field. The critical part is how RHF’s useForm() hook integrates with Ionic components:

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;

The useForm() hook returns a control object and a handleSubmit function. The latter passes validated input values to your handler. The <Controller /> component registers the controlled component with RHF. By setting its onChangeName prop to Ionic’s change event name, RHF receives updates correctly.

Since you may want multiple fields, writing the same boilerplate repeatedly becomes tedious. A reusable Input component in src/components/Input.tsx cleans this up:

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 wrapper takes a required name prop plus optional control, component, and label props. It renders the Ionic components internally, so defining fields in your form component becomes a matter of listing configuration objects:

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;

At this stage, the app running at https://localhost:8100 should display a form with the configured fields. You can further externalize field data into a JSON file to keep component code lean.

Adding Validation Logic

Without validation, malformed data can reach your backend. RHF supports HTML-standard validation rules for simple checks like required fields or length constraints. For complex validation—such as email format checking—the Yup library integrates directly with RHF. Install it along with its typings:

yarn add yup @types/yup

After importing Yup into your component, define a validation schema. Field names in the schema must match the name props in your form inputs:

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

Wire the schema into useForm() by setting the validationSchema property:

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

Now submission is blocked when validation fails, but the user receives no feedback—validation happens silently. Update the reusable Input component to accept an optional error object from RHF and render a message when an error exists:

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;

Then update the form component to destructure errors from the useForm() hook and pass the relevant error object to each Input:

  {formFields.map((field, index) => (
    <Input {...field} control={control} key={index} errors={errors} />
  ))}

The form now shows visual cues for invalid entries. Yup allows custom error messages by passing a string to the validation method—for example, a human-readable email message:

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

Making Inputs Accessible

Ionic components wrap native elements and inherit most of their attributes, including ARIA properties. You can improve screen reader support in the Input component by adding attributes that announce errors:

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;

This modification adds aria-invalid to mark an erroneous field and aria-describedby to reference the error message below. Wrapping the error text in a span with role="error" ensures screen readers announce the message when the field fails validation. For further reading on form components and validation, the official Ionic documentation and the React Hook Form site are references, along with Yup’s documentation and MDN’s ARIA guide.