TypeScript: Типизация событий формы в React

Обработка событий в React очень близка к тому, как обрабатываются события на элементах DOM. Есть несколько незначительных отличий, например, имена событий следуют соглашению о верблюжьем регистре, в то время как в DOM они все строчные; в качестве обработчика события передается сама функция, а не ее имя в строковой форме. Однако самое большое отличие заключается в том, что React оборачивает собственные события DOM в SyntheticEvent, заставляя их вести себя несколько иначе, чем собственные события. В документации React дается подробное объяснение особенностей синтетических событий. Следовательно, типизация событий формы в React отличается от типизации нативных событий, поскольку React предоставляет свои собственные типы. В этой заметке мы рассмотрим, как набирать события формы в React на примере простого компонента, а также обсудим наиболее распространенные подводные камни.

В качестве примера воспользуемся простой формой регистрации:

export const Form = () => {
    return (
        <form className="form">
            <div className="field">
                <label htmlFor="name">Name</label>
                <input id="name" />
            </div>
            <div className="field">
                <label htmlFor="email">Email</label>
                <input type="email" id="email" />
            </div>
            <div className="field">
                <label htmlFor="password">Password</label>
                <input type="password" id="password" />
            </div>
            <div className="field">
                <label htmlFor="confirmPassword">Confirm password</label>
                <input type="password" id="confirmPassword" />
            </div>
            <div className="field checkbox">
                <input type="checkbox" id="conditionsAccepted" />
                <label htmlFor="conditionsAccepted">I agree to the terms and conditions</label>
            </div>
            <button type="submit">Sign up</button>
        </form>
    );
};
Вход в полноэкранный режим Выход из полноэкранного режима

Если мы хотим собрать данные формы и отправить их на сервер, есть два основных варианта. Первый — оставить форму неконтролируемой и получать данные в обратном вызове onSubmit, а второй — хранить данные в состоянии формы и отправлять их при ее отправке. Здесь мы рассмотрим оба подхода.

Неконтролируемая форма

Чтобы получить данные формы при отправке, мы добавим обратный вызов onSubmit и получим данные из каждого элемента через его свойство name.

export const Form = () => {
    const onSubmit = (event: any) => {
        event.preventDefault();
        // Validate form data
        // ...

        const target = event.target;

        const data = {
            name: target.name.value,
            email: target.email.value,
            password: target.password.value,
            confirmPassword:target.confirmPassword.value,
            conditionsAccepted: target.conditionsAccepted.checked,
        };

        console.log(data);
    };

    return (
        <form className="form" onSubmit={onSubmit}>
            <div className="field">
                <label htmlFor="name">Name</label>
                <input id="name" />
            </div>
            <div className="field">
                <label htmlFor="email">Email</label>
                <input type="email" id="email" />
            </div>
            <div className="field">
                <label htmlFor="password">Password</label>
                <input type="password" id="password" />
            </div>
            <div className="field">
                <label htmlFor="confirmPassword">Confirm password</label>
                <input type="password" id="confirmPassword" />
            </div>
            <div className="field checkbox">
                <input type="checkbox" id="conditionsAccepted" />
                <label htmlFor="conditionsAccepted">I agree to the terms and conditions</label>
            </div>
            <button type="submit">Sign up</button>
        </form>
    );
};
Вход в полноэкранный режим Выход из полноэкранного режима

Для простоты мы опустим здесь проверку данных и обработку ошибок (а также CSS-стилирование). Важно сначала вызвать event.preventDefault();, потому что при отправке формы произойдет перезагрузка страницы, прежде чем мы сможем собрать данные. Возврат false из обратного вызова здесь не сработает, что является одним из отличий синтетических событий от собственных событий DOM. Для сбора данных формы мы можем либо напрямую получить доступ к ним из цели события по id элемента, либо по свойству elements цели, например, работают оба варианта email: event.target.email.value и email: event.target.elements.email.value. Также можно использовать атрибут name для получения значения элемента формы, но так как у нас уже настроены идентификаторы (для связи наших входов с их метками через атрибут htmlFor), мы будем использовать здесь id.

Вы заметите, что тип события any, что означает отсутствие проверки типа. Чтобы исправить это, нам нужно будет определить тип события для обратного вызова onSubmit. Для синтетических событий мы будем использовать определения типов, предоставляемые React. Первым вариантом будет использование React.FormEvent с аргументом типа HTMLFormElement. Этот подход, хотя обычно и правильный, не подходит для нашей формы, потому что мы хотим получить данные из атрибута target, который получает общий тип EventTarget. Это происходит потому, что тип FormEvent в конечном итоге сводится к BaseSyntheticEvent<E, EventTarget & T, EventTarget>, где последний аргумент EventTarget используется для типа атрибута target. Кажется, что у нас нет контроля над типом атрибута target, и даже если бы он был (мы всегда можем утверждать тип, если это необходимо), он все равно не знает, какие элементы формы имеет наша форма. Заглянув глубже в определение типа BaseSyntheticEvent, мы видим, что второй аргумент типа EventTarget & T присваивается свойству currentTarget события. Похоже, что для решения проблемы с типом нам нужно просто переключиться с event.target на event.currentTarget.

const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const target = event.currentTarget;

    const data = {
        name: target.name.value,
        email: target.email.value,
        password: target.password.value,
        confirmPassword: target.confirmPassword.value,
        conditionsAccepted: target.conditionsAccepted.checked,
    };

    console.log(data);
};
Вход в полноэкранный режим Выход из полноэкранного режима

Это почти работает и работало бы правильно, если бы у нас не было элемента ввода с id name в форме. В текущем виде наш currentTarget.name переопределяет атрибут name элемента HTMLFormElement. Однако есть и более серьезная проблема, связанная с этим подходом, а именно отсутствие надлежащей проверки типов атрибутов цели. Например, мы можем попытаться получить доступ к несуществующему элементу формы через address: target.address.value и это не будет поймано TS, потому что он не знает, какие элементы есть в форме. Было бы лучше, если бы мы могли определять пары id — элемент и соответственно вводить текущую цель. Для этого нам нужно посмотреть на определение типа HTMLFormElement. Мы видим, что у него есть элементы readonly: HTMLFormControlsCollection;, с удобной документацией — Получает коллекцию, в порядке источника, всех элементов управления в данной форме. Это атрибут event.target.elements, о котором мы говорили ранее, только в данном случае он находится на currentEvent.

Соединив все, кажется, что мы можем расширить HTMLFormControlsCollection с нашими элементами формы и затем перезаписать HTMLFormElement.elements с этим интерфейсом.

import React, { FormEvent } from "react";
import "./form.css";

interface CustomElements extends HTMLFormControlsCollection {
  name: HTMLInputElement;
  email: HTMLInputElement;
  password: HTMLInputElement;
  confirmPassword: HTMLInputElement;
  conditionsAccepted: HTMLInputElement;
}

interface CustomForm extends HTMLFormElement {
  readonly elements: CustomElements;
}

export const Form = () => {
  const onSubmit = (event: FormEvent<CustomForm>) => {
    event.preventDefault();
    const target = event.currentTarget.elements;

    const data = {
      name: target.name.value,
      email: target.email.value,
      password: target.password.value,
      confirmPassword: target.confirmPassword.value,
      conditionsAccepted: target.conditionsAccepted.checked,
    };

    console.log(data);
  };

  return (
    <form className="form" onSubmit={onSubmit}>
      <div className="field">
        <label htmlFor="name">Name</label>
        <input id="name" />
      </div>
      <div className="field">
        <label htmlFor="email">Email</label>
        <input type="email" id="email" />
      </div>
      <div className="field">
        <label htmlFor="password">Password</label>
        <input type="password" id="password" />
      </div>
      <div className="field">
        <label htmlFor="confirmPassword">Confirm password</label>
        <input type="password" id="confirmPassword" />
      </div>
      <div className="field checkbox">
        <input type="checkbox" id="conditionsAccepted" />
        <label htmlFor="conditionsAccepted">I agree to the terms and conditions</label>
      </div>
      <button type="submit">Sign up</button>
    </form>
  );
};
Вход в полноэкранный режим Выйти из полноэкранного режима

Это решит проблему типов, а также обеспечит правильную проверку типов при доступе к значениям элементов формы.

Управляемая форма

Хотя полезно знать, как правильно набирать обработчики событий неконтролируемой формы, этот вид формы не очень часто встречается в компонентах React. Чаще всего мы хотим, чтобы форма имела управляемые элементы, значения/обратные вызовы для которых также могут поступать от родительских компонентов. В таких случаях принято сохранять значения формы в состоянии компонента, а затем отправлять их на сервер при отправке формы, или даже без использования события submit формы. К счастью, написание таких обработчиков событий более простое, чем в примере с формой выше.

В качестве примера, давайте сохраним значения, введенные пользователем, в состояние и затем отправим их через API, когда пользователь нажмет Sign up. Для простоты мы будем использовать один объект state (аналогично тому, как сохраняется состояние в компонентах, основанных на классах).

const [state, setState] = useState({
    name: "",
    email: "",
    password: "",
    confirmPassword: "",
    conditionsAccepted: false,
});
Вход в полноэкранный режим Выход из полноэкранного режима

Часто предпочтительнее использовать хук useReducer для обработки сложного состояния, однако для данной демонстрации подойдет useState. Чтобы обновить значения элементов формы в зависимости от состояния, нам нужно назначить обработчики onChange для каждого из полей. Наиболее очевидный вариант — назначить каждому элементу свой обработчик onChange, например:

 <input
  id="name"
  onChange={(event) => setState({ ...state, name: event.target.value })}
/>
Войти в полноэкранный режим Выйти из полноэкранного режима

Однако есть и более простой способ. Подобно тому, как событие submit формы имеет все идентификаторы или имена элементов, сохраненные в цели, цель события change имеет атрибуты name и id, которые могут быть использованы для присвоения значения соответствующего элемента состоянию. Поскольку у нас уже есть идентификаторы, определенные для полей (и эти идентификаторы имеют те же имена, что и поля, из которых мы собираем данные), мы будем использовать их для сопоставления данных поля с состоянием. Однако в этом подходе есть небольшая проблема, которая заключается в том, что у нас есть один элемент checkbox. Для элементов checkbox мы ищем свойство checked вместо value. В таких случаях хорошо иметь отдельный обработчик для каждого типа элемента, например, onInputChange и onCheckboxChange, однако, поскольку у нас простая форма с одним флажком, давайте добавим условие в обработчик, который будет получать свойство checked для поля checkbox на основе типа цели.

export const Form = () => {
  const [state, setState] = useState({
    name: "",
    email: "",
    password: "",
    confirmPassword: "",
    conditionsAccepted: false,
  });

  const onFieldChange = (event: any) => {
    let value = event.target.value;
    if (event.target.type === "checkbox") {
      value = event.target.checked;
    }

    setState({ ...state, [event.target.id]: value });
  };

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    console.log(state);
  };

  return (
    <form className="form" onSubmit={onSubmit}>
      <div className="field">
        <label htmlFor="name">Name</label>
        <input id="name" onChange={onFieldChange} />
      </div>
      <div className="field">
        <label htmlFor="email">Email</label>
        <input type="email" id="email" onChange={onFieldChange} />
      </div>
      <div className="field">
        <label htmlFor="password">Password</label>
        <input type="password" id="password" onChange={onFieldChange} />
      </div>
      <div className="field">
        <label htmlFor="confirmPassword">Confirm password</label>
        <input type="password" id="confirmPassword" onChange={onFieldChange} />
      </div>
      <div className="field checkbox">
        <input type="checkbox" id="conditions" onChange={onFieldChange} />
        <label htmlFor="conditions">I agree to the terms and conditions</label>
      </div>
      <button type="submit">Sign up</button>
    </form>
  );
};
Вход в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, что мы также вернулись к типу FormEvent<HTMLFormElement> для события submit, поскольку мы больше не обращаемся к значениям из него. Теперь нам просто нужно ввести событие onChange. Сначала мы пытаемся ввести событие как React.ChangeEvent, однако этого оказывается недостаточно, так как мы получаем ошибку типа, что все свойства, к которым мы пытаемся получить доступ, не существуют в цели события. Заглянув в определение ChangeEvent, мы видим, что оно принимает аргумент типа, который по умолчанию равен общему Element. Поскольку все элементы формы являются элементами ввода, мы передадим этот тип в ChangeEventconst onFieldChange = (event: ChangeEvent<HTMLInputElement>) => {. Теперь все атрибуты цели распознаются правильно, однако при переназначении event.target.checked значению возникает новая проблема — Type 'boolean' is not assignable to type 'string'. Это происходит потому, что когда мы объявляем значение с помощью let value = event.target.value; TS определяет его тип как string, который является типом для значения входа. Чтобы исправить это, нам нужно сообщить TS, что наше значение может быть и булевым: let value: string | boolean = event.target.value;. Это хорошо работает, однако не лучше ли было бы, если бы TS мог автоматически определять тип значения на основе состояния? Например, допустим, мы добавляем новое поле age, которое вводится с типом число. Чтобы сохранить его в состоянии, мы добавим этот блок в onFieldChange:

else if (event.target.type === "number") {
    value = parseInt(event.target.value, 10);
}
Войти в полноэкранный режим Выход из полноэкранного режима

Чтобы избавиться от ошибки TS, нам придется обновить тип значения до boolean | string | number. Но что, если мы в конце концов решим удалить поле возраста? Нам также придется не забыть обновить типы значений. Мы могли бы определить отдельный тип State, в котором мы объявим типы для всех полей, однако есть лучший способ. У нас уже есть типы значений состояния, доступные из состояния, и мы знаем, что эти типы — единственные, которые будут у каждого поля. В таких случаях лучше выводить типы из существующих данных, так как это позволит автоматически синхронизировать их в случае изменения структуры данных. Чтобы вывести типы из состояния, мы можем использовать аккуратную нотацию TS — let value: typeof state[keyof typeof state] = event.target.value;. Здесь мы говорим TS, что ожидаем, что value будет типом значений, присутствующих в состоянии, и если в будущем любое из них изменится, эти изменения будут автоматически отражены в типе value. Окончательный код формы выглядит следующим образом:

import React, { ChangeEvent, FormEvent, useState } from "react";
import "./form.css";

export const Form = () => {
    const [state, setState] = useState({
        name: "",
        email: "",
        password: "",
        confirmPassword: "",
        conditionsAccepted: false,
    });

    const onFieldChange = (event: ChangeEvent<HTMLInputElement>) => {
        let value: typeof state[keyof typeof state] = event.target.value;
        if (event.target.type === "checkbox") {
            value = event.target.checked;
        }

        setState({ ...state, [event.target.id]: value });
    };

    const onSubmit = (event: FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        console.log(state);
    };

    return (
        <form className="form" onSubmit={onSubmit}>
            <div className="field">
                <label htmlFor="name">Name</label>
                <input id="name" onChange={onFieldChange} />
            </div>
            <div className="field">
                <label htmlFor="email">Email</label>
                <input type="email" id="email" onChange={onFieldChange} />
            </div>
            <div className="field">
                <label htmlFor="password">Password</label>
                <input type="password" id="password" onChange={onFieldChange} />
            </div>
            <div className="field">
                <label htmlFor="confirmPassword">Confirm password</label>
                <input type="password" id="confirmPassword" onChange={onFieldChange} />
            </div>
            <div className="field checkbox">
                <input type="checkbox" id="conditions" onChange={onFieldChange} />
                <label htmlFor="conditions">I agree to the terms and conditions</label>
            </div>
            <button type="submit">Sign up</button>
        </form>
    );
};
Вход в полноэкранный режим Выход из полноэкранного режима

Оцените статью
devanswers.ru
Добавить комментарий