Biblioteki do formularzy w React #2 – react-hook-form

Ten post jest kontynuacją serii o obsłudze formularzy w React:

Spis Treści

  1. Wprowadzenie
  2. Prosty przykład
    1. Baza do dalszych prac
    2. Walidacja
      1. Prosta walidacja
      2. Customowa walidacja
      3. Asynchroniczna walidacja
      4. Wiadomości dla błędów walidacji
    3. Obsługa stanu formularza – obiekt formState
      1. Informowanie o tym, że trwa wysyłanie formularza
      2. Oznaczanie pól, które zmodyfikował użytkownik
    4. Pełny kod prostego przykładu
  3. Zaawansowany przykład
    1. Obsługa tablic i obiektów jako pól formularza
    2. Walidacja rozmiarów obrazka
    3. Pełny kod pierwszej wersji
    4. Ostateczna wersja
    5. Pełny kod wersji ostatecznej
  4. DevTools dla React Hook form
    1. Podpięcie DevTools
    2. Wyświetlanie DevTools tylko podczas developmentu
  5. Podsumowanie

Wprowadzenie

Jeżeli szukasz biblioteki, która pozwoli Ci zachować formularze proste i czytelne, nie powodując dużego narzutu na kod i nie narzucając zbyt wielu własnych rozwiązań – a jednocześnie dającej duże możliwości zarządzania stanem formularza, walidacją itd to react-hook-form jest rozwiązaniem stworzonym dla Ciebie.

Czytelne i proste API oparte na hookach, dedykowane narzędzie do debugowania powodują, że react-hook-form jest rozwiązaniem pierwszego wyboru do zarządzania formularzami.


Prosty przykład

Zainstalujmy bibliotekę poleceniem:

npm install react-hook-form

Baza do dalszych prac

Napiszmy pierwszy przykład użycia biblioteki:

import React from "react";
import { useForm } from "react-hook-form";

export default function App() {
  const { register, handleSubmit } = useForm();
  const onSubmit = data => alert(JSON.stringify(data));

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input name="userName" ref={register} />
      <button type="submit">
        Submit
      </button>
    </form>
  );
}

Używamy hooka useForm, z którego pobieramy 2 funkcje – handleSubmit, która przyjmuje funkcję mającą odpalić się na onSubmit i wstrzykuje do niej dane z formularza, oraz funkcję register która służy do rejestrowania pola w formularzu pod nazwą przekazaną polu w parametrze name. Nie musimy sami przetrzymywać i aktualizować stanu formularza, zrobi to za nas biblioteka.

Walidacja

Prosta walidacja

Jeżeli chcemy oznaczyć pole jako wymagane, zamiast przekazywać do ref funkcję jako parametr, możemy wywołać ją z obiektem parametrów {required: true}:

<input name="userName" ref={register({required: true})} />

Spowoduje to, że przy pustym polu formularz nie będzie uruchamiał funkcji onSubmit.
Jeżeli chcemy poinformować użytkownika o błędzie, z hooka useForm możemy pobrać obiekt errors który będzie posiadał pole userName zawierające obiekt błędu:

const { register, handleSubmit, errors } = useForm();

W naszym przypadku errors.userName wygląda następująco:

{
  message: '',
  ref: input, // referencja do HTMLInput
  type: 'required',
}

Możemy warunkowo wyświetlać informację o błędzie opierając się na errors.userName umieszczając ją np pod inputem:

{errors.userName && <span>Field is required</span>}

Rozszerzmy walidację o minimalną i maksymalną długość pola za pomocą minLength i maxLength:

<input
  name="userName"
  ref={register({required: true, minLength: 5, maxLength: 10})} />

{errors.userName?.type === 'required' && <span>Field is required</span>}
{errors.userName?.type === 'minLength' && <span>At least 5 chars</span>}
{errors.userName?.type === 'maxLength' && <span>At most 10 chars</span>}

Błędy rozróżniamy po typie – dodatkowo musimy zabezpieczyć się przed sytuacją, gdy błędu nie ma i obiekt errors jest pusty. Można napisać kod w starym stylu:

{errors.userName && errors.userName.type === 'required' && <span>Field is required</span>}

Osobiście zachęcam Cię jednak do używania Optional Chaining Operator, który jest w standardzie EcmaScript od wersji 2020.

Customowa walidacja

Możemy również utworzyć własną funkcję walidującą i przekazać ją do kolejnego pola w formularzu:

const isEven = value => {
  const number = Number(value);
  return !Number.isNaN(number) && !(number % 2);
};

Funkcja ta sprawdza, czy wartość wpisana w polu jest liczbą i czy jest parzysta. Możemy łączyć walidacje natywne dla biblioteki i nasze własne funkcje walidujące:

<input
  name="evenNumber"
  ref={register({required: true, validate: isEven})}
  inputMode="numeric"
  pattern="[0-9]*" 
/>
{errors.evenNumber?.type === 'required' && <span>Number is required</span>}
{errors.evenNumber?.type === 'validate' && <span>Should be even</span>}

Rezultat naszej własnej walidacji ma typ validate.
Może Cię zastanawiać czemu użyłem tak skomplikowanego zestawu parametrów dla inputa oraz rzutowania wewnątrz funkcji walidującej, zamiast typu number. Otóż okazuje się, że ten typ ma problemy na urządzeniach mobilnych.

Asynchroniczna walidacja

Walidować możemy również w asynchroniczny, gdzie funkcja validate będzie zwracać Promise resolvujący się z true lub false. Dodajmy pole pseudo captcha:

<input
name="captcha"
placeholder="Type 'test'"
ref={register({required: true, validate: testCaptcha})}
/>
{errors.captcha?.type === 'required' && <span>Captcha is required</span>}
{errors.captcha?.type === 'validate' && <span>Should type 'test'</span>}

Sama funkcja walidująca wygląda tak:

const testCaptcha = async value => {
const valid = await sendRequest(value);
return valid;
}

Zapytanie na serwer zostało zasymulowane poprzez Promise i setTimeout:

const sendRequest = value => new Promise((resolve, reject) => {
setTimeout(() => {
const valid = value === 'test';
resolve(valid)
}, 5000)
})

Efekt sumaryczny jest następujący:

  1. Użytkownik wpisuje złe dane, np test2
  2. Klika „Send”
  3. Po 5 sekundach widzi komunikat, że wpisał złe dane

Jednocześnie, dzięki temu że możemy łączyć walidacje customowe z dostarczanymi przez bibliotekę, z powodu oznaczenia pola jako required jeżeli użytkownik nie wpisze nic informację o tym by coś wpisać dostanie od razu.

Wiadomości dla błędów walidacji

Do tej pory wiadomości dla błędów walidacji renderowaliśmy za pomocą conditional render. Treść wiadomości można jednak zdefiniować już przy rejestrowaniu inputu. Zamiast samej wartości walidacji, przekazać należy obiekt z polami value i message:

register({required: true, minLength: 3});
// Poniższy zapis jest równoważny:
register({
  required: {value: true, message: 'Type something'},
  minLength: {value: 3, message: 'Type at least 3 characters'},
})

Dodajmy do formularza pole zawierające taką walidację:

<input
name="userDesc"
ref={register({
required: {value: true, message: 'Type something'},
minLength: {value: 3, message: 'Type at least 3 characters'},
})
}
/>
{errors.userDesc && <span>{errors.userDesc.message}</span>}

Pierwszą zaletą jest to, że kodu jest mniej i stał się czystszy. Drugą – że takie obiekty walidacji można definiować jako stałe i je reużywać, a nawet pobierać backendu, gdzie np adminstrator aplikacji będzie je definiował w CMS.

Obsługa stanu formularza – obiekt formState

Z wywołania hooka useForm możemy pobrać również obiekt formState:

const { register, handleSubmit, errors, formState } = useForm();

Informowanie o tym, że trwa wysyłanie formularza

Obiekt formState udostępnia pole isSubmitting, którego możemy użyć do poinformowania użytkownika o tym, że trwa wysyłanie formularza i do zablokowania przycisku:

<button type="submit" disabled={formState.isSubmitting}>
  {formState.isSubmitting? 'Submitting...' : 'Submit'}
</button>

Oznaczanie pól które użytkownik zmodyfikował

Aby zasygnalizować użytkownikowi pola które zmodyfikował, możemy użyć polaformState.dirtyFields które jest Setem zawierającym zmodyfikowane pola. Jeżeli do pola userName dodamy:

style={{borderColor: formState.dirtyFields.has('userName')? 'green' : 'blue'}}

To pole na start będzie niebieskie, a jeżeli użytkownik coś wpisze to zmieni kolor na zielony.

Pełny kod prostego przykładu

Ponieważ pełny kod zajmuje dużo miejsca, jest on ukryty – kliknij przycisk poniżej by go zobaczyć. Kod nadaje się do skopiowania i uruchomienia.

Pokaż pełny kod
import React from "react";
import { useForm } from "react-hook-form";

const sendRequest = value => new Promise((resolve, reject) => {
setTimeout(() => {
const valid = value === 'test';
resolve(valid)
}, 5000)
})

const isEven = value => {
const number = Number(value);
return !Number.isNaN(number) && !(number % 2);
};

const testCaptcha = async value => {
const valid = await sendRequest(value);
return valid;
}

export default function App() {
const { register, handleSubmit, errors, formState } = useForm();
const onSubmit = data => alert(JSON.stringify(data));

return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
name="userName"
ref={register({required: true, minLength: 5, maxLength: 10})}
style={{borderColor: formState.dirtyFields.has('userName')? 'green' : 'blue'}}
/>
{errors.userName?.type === 'required' && <span>Field is required</span>}
{errors.userName?.type === 'minLength' && <span>At least 5 characters</span>}
{errors.userName?.type === 'maxLength' && <span>At most 10 characters</span>}
<br />

<input
name="evenNumber"
ref={register({required: true, validate: isEven})}
inputMode="numeric"
pattern="[0-9]*"
style={{borderColor: formState.dirtyFields.has('evenNumber')? 'green' : 'blue'}}
/>
{errors.evenNumber?.type === 'required' && <span>Number is required</span>}
{errors.evenNumber?.type === 'validate' && <span>Should be even</span>}
<br />

<input
name="captcha"
placeholder="Type 'test'"
ref={register({required: true, validate: testCaptcha})}
style={{borderColor: formState.dirtyFields.has('captcha')? 'green' : 'blue'}}
/>
{errors.captcha?.type === 'required' && <span>Captcha is required</span>}
{errors.captcha?.type === 'validate' && <span>Should type 'test'</span>}
<br />

<input
name="userDesc"
ref={register({
required: {value: true, message: 'Type something'},
minLength: {value: 3, message: 'Type at least 3 characters'},
})
}
style={{borderColor: formState.dirtyFields.has('userDesc')? 'green' : 'blue'}}
/>
{errors.userDesc && <span>{errors.userDesc.message}</span>}
<br />

<button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting? 'Submitting...' : 'Submit'}
</button>
</form>
);
}

Zaawansowany przykład

Napiszmy prostą galerię obrazków, do której będzie można dodawać obrazy tylko wtedy, gdy obraz nie przekroczy określonych wymiarów.

Obsługa tablic i obiektów jako pól formularza

Bilbioteka react-hook-form potrafi zarządzać tablicami jako elementami formularza. Jeżeli do pola name inputa dodamy numer w nawiasach kwadratowych, będzie to oznaczać element tablicy, np inputy

<input name="users[0]" />
<input name="users[1]" />

Reprezentować będą tablicę users z dwoma elementami.

Mamy również możliwość obsługi zagnieżdżonych obiektów, przykładowo

<input name="user.name" />
<input name="user.lastName" />

Reprezentować będzie następującą strukturę:

user: {
  name: '',
  lastName: '',
}

Formularz dodawania obrazków w pierwszej wersji używać będzie takiej struktury danych:

images: [
  {
    name: '',
    file: null, // plik
  }
]

A więc tablicy obiektów z polami name i file. Taką strukturę stworzymy deklarując pola w następujący posób:

<input
  name={`images[${i}].name`}
  placeholder="Name"
/>
<input
  name={`images[${i}].files`}
  type="file"
  placeholder="File"
/>

Analogicznie błędy dla poszczególnych obrazków będą w errors.images[i].name i errors.images[i].file.

Walidacja rozmiarów obrazka

Ponieważ będziemy chcieli walidować obrazek pod kątem rozmiaru, musimy napisać asynchroniczną funkcję która przetworzy plik na obrazek, załaduje go a następnie sprawdzi rozmiary. Może wyglądać na przykład tak:

const validateImage = async files => new Promise(resolve => {
  const url = URL.createObjectURL(files[0])
  const image = new Image()
  image.onload = () => {
    const valid = image.width <= 1000 && image.height <= 1000;
    URL.revokeObjectURL(url)
    resolve(valid? true : 'Image size is at most 1000 x 1000')
  }
  image.src = url;
})

Nie ma w tym momencie możliwości zadeklarowania wiadomości dla błędu walidacji customowej funkcji podczas rejestracji pola, stąd korzystamy z drugiej możliwości – zwrócenia tekstu błędu zamiast wartości boolean. Zachowanie jest następujące – jeżeli zwrotka to true, błędu nie ma. Jeżeli string, błąd jest a tekst wiadomości ma wartość tego stringa.


Pamiętamy również o tym, by po użyciu usunąć obrazek za pomocą URL.revokeObjectURL, ponieważ obrazki których adresy są utworzone przez URL.createObjectURL są przetrzymywane przez przeglądarkę do zamknięcia strony – a szkoda zajmować pamięć niepotrzebnie.

Pełny kod pierwszej wersji

Kod nadaje się do skopiowania i uruchomienia. Jest dość spory, stąd jest ukryty.

Pokaż pełny kod
import React, {useState} from "react";
import { useForm } from "react-hook-form";

const validateImage = async files => new Promise(resolve => {
  const url = URL.createObjectURL(files[0])
  const image = new Image()
  image.onload = () => {
    const valid = image.width <= 1000 && image.height <= 1000;
    URL.revokeObjectURL(url)
    resolve(valid? true : 'Image size is at most 1000 x 1000')
  }
  image.src = url;
})

const FormElement = ({i, errors, register}) =>(
  <div style={{display: 'inline-block'}}>
    <p>
      Image number {i}:
    </p>
    <input
      name={`images[${i}].name`}
      ref={register({
        required: {
          value: true,
          message: 'Provide image name',
        }
      })}
      placeholder="Name"
    />
    {errors.images?.[i]?.name?.message}
    <br/>
    <input
      name={`images[${i}].files`}
      type="file"
      ref={register({
        required: {
          value: true,
          message: 'Provide image',
        },
        validate: validateImage
      })}
      placeholder="File"
    />
    {errors.images?.[i]?.files?.message}
  </div>
)

export default function App() {
  const { register, handleSubmit, errors } = useForm();

  const [ids, setIds] = useState([])
  const [images, setImages] = useState([])

  const onSubmit = data => {
    setImages([
      ...images,
      ...data.images.map(img => ({...img, src: URL.createObjectURL(img.files[0])}))
    ]);
    setIds([])
  };

  return (
    <>
      <form onSubmit={handleSubmit(onSubmit)}>
        {ids.map((id, i) => (
          <FormElement i={i} errors={errors} register={register} key={id}/>
        ))}
        <button type="button" onClick={() => setIds([...ids, new Date().getTime()])}>
          Add
        </button>
        <br />
        <button type="submit">
          Submit
        </button>
      </form>
      <p>
        Images:
        <p>
          {images.map(el => (
            <div style={{display: 'inline-block'}} key={el.name}>
              <strong>{el.name}</strong>
              <br />
              <img src={el.src} alt=""/>
            </div>
            ))}
        </p>
      </p>
    </>
  );
}

Ostateczna wersja

Z obecną wersją jest kilka problemów – pliki trzymane są w dziwnej formie, jako lista plików. Dodatkowo, nie da się trzymać w formularzu naszych własnych wartości – przykładowo rozmiarów obrazka a ewidentnie byłoby to przydatne.
Z pomocą przychodzi nam definiowanie customowych pól formularza poprzez wywołanie register ręcznie.

Pole rejestrujemy wewnątrz metody useEffect:

useEffect(() => {
register({name: `images[${i}]`}, {
required: {value: true, message: 'Provide name and file'},
validate: validateImage,
})
// did mount - empty array on purpose
// eslint-disable-next-line
}, [])

Ponieważ musimy mieć pewność że pole zarejestruje się tylko raz, przekazujemy pustą tablicę jako drugi parametr. Dobrze też wyłączyć dla tej linii walidację ESLint, ponieważ w konsoli wyświetlane są ostrzeżenia.

Pole w formularzu będzie obiektem o interfejsie:

{
  file: File,
  name: string,
  width: number,
  height: number,
}

Stąd walidacja required sprawdzi tylko czy zamiast obiektu nie ma wartości null bądź undefined. Dlatego obsługa sytuacji braku nazwy pliku bądź samego pliku musi zostać rozwiązana w customowej funkcji walidującej:

const validateField = async el => new Promise(resolve => {
  if (!el?.name) {
    resolve('Provide name');
  }
  if (!el?.file) {
    resolve('Provide file');
  }
  const url = URL.createObjectURL(el.file)
  const image = new Image()
  image.onload = () => {
    const valid = image.width <= 1000 && image.height <= 1000;
    URL.revokeObjectURL(url);
    resolve(valid? true : 'Image size is at most 1000 x 1000');
  }
  image.src = url;
})

Jak pamiętasz, zwrócenie stringa oznacza błąd z wiadomością będącą tym stringiem.

Również ręcznie ustawiać należy wartość tego obiektu:

const { register, handleSubmit, errors, setValue } = useForm();

// W komponencie pola obrazka

<input
  onChange={event => {
    const newName = event.target.value;
    setName(newName)
    setValue(`images[${i}]`, {
      name: newName,
      ...file,
    })
  }}
  placeholder="Name"
/>

Wartości pól zapisujemy również lokalnie, dlatego by móc z dowolnego pola aktualizować wartości w obiekcie.

Dzięki możliwości załadowania obrazka lokalnie możemy odczytać jego rozmiary i zapisać je w polu formularza:

onChange={event => {
  const file = event.target.files[0]
  const src = URL.createObjectURL(file)
  const image = new Image()
  image.onload = () => {
    const newFile = {
      file,
      width: image.width,
      height: image.height,
    };
    setFile(newFile)
    setValue(`images[${i}]`, {
      name,
      ...newFile,
    })
    URL.revokeObjectURL(src)
  }
  image.src = src
}}

// Przy wyświetlaniu obrazków
<strong>{el.name}, width: {el.width} and height: {el.height}</strong>

Pełny kod wersji ostatecznej

Kod ma aż 130 linii, dzięki temu jednak dobrze pokazuje możliwości biblioteki react-hook-form, oraz nadaje się do uruchomienia po przekopiowaniu do aplikacji z zainstalowanymi zależnościami:

Pokaż pełny kod
import React, {useEffect, useState} from "react";
import { useForm } from "react-hook-form";

const validateField = async el => new Promise(resolve => {
if (!el?.name) {
resolve('Provide name');
}
if (!el?.file) {
resolve('Provide file');
}
const url = URL.createObjectURL(el.file);
const image = new Image();
image.onload = () => {
const valid = image.width <= 1000 && image.height <= 1000;
URL.revokeObjectURL(url);
resolve(valid? true : 'Image size is at most 1000 x 1000')
}
image.src = url;
})

const FormElement = ({i, errors, register, setValue}) => {
const [name, setName] = useState('')
const [file, setFile] = useState({
file: null,
width: null,
height: null,
})
useEffect(() => {
register({name: `images[${i}]`}, {
required: {value: true, message: 'Provide name and file'},
validate: validateField,
})
// did mount - empty array on purpose
// eslint-disable-next-line
}, [])

return (
<div style={{display: 'inline-block'}}>
<p>
Image number {i}:
</p>
<input
onChange={event => {
const newName = event.target.value;
setName(newName)
setValue(`images[${i}]`, {
name: newName,
...file,
})
}}
placeholder="Name"
/>
{errors.images?.[i]?.name?.message}
<br/>
<input
type="file"
placeholder="File"
onChange={event => {
const file = event.target.files[0]
const src = URL.createObjectURL(file)
const image = new Image()
image.onload = () => {
const newFile = {
file,
width: image.width,
height: image.height,
};
setFile(newFile)
setValue(`images[${i}]`, {
name,
...newFile,
})
URL.revokeObjectURL(src)
}
image.src = src
}}
/>
<br />
{errors.images?.[i]?.message}
</div>
);
}

export default function App() {
const { register, handleSubmit, errors, setValue } = useForm();

const [ids, setIds] = useState([])
const [images, setImages] = useState([])

const onSubmit = data => {
setImages([
...images,
...data.images.map(img => ({...img, src: URL.createObjectURL(img.file)}))
]);
setIds([])
};

return (
<>
<form onSubmit={handleSubmit(onSubmit)}>
{ids.map((id, i) => (
<FormElement i={i} errors={errors} register={register} setValue={setValue} key={id}/>
))}
<button type="button" onClick={() => setIds([...ids, new Date().getTime()])}>
Add
</button>
<br />
<button type="submit">
Submit
</button>
</form>
<p>
Images:
<p>
{images.map(el => (
<div style={{display: 'inline-block'}} key={el.name}>
<strong>{el.name}, width: {el.width} and height: {el.height}</strong>
<br />
<img src={el.src} alt=""/>
</div>
))}
</p>
</p>
</>
);
}

DevTools dla React Hook form

Bibliotekę należy zainstalować poleceniem:

npm install @hookform/devtools -D

Podpięcie DevTools

Podpięcie jest bardzo proste, najpierw należy z hooka zaimportować kontroler:

const { register, handleSubmit, errors, setValue, control } = useForm();

A następnie gdzieś wstawić komponent DevTools:

<DevTool control={control} />

Po prawej stronie pojawi się komponent zawierający dane z naszego formularza:

Wyświetlanie DevTools tylko podczas developmentu

Nie chcemy oczywiście, by docelowa aplikacja na produkcji miała takie elementy.
Jeżeli nasza aplikacja została stworzona za pomocą CRA, skrypty same troszczą się o to czy wersja jest developerska czy produkcyjna. Jeżeli chcemy to sprawdzić, mamy wystawioną zmienną process.env.NODE_ENV. Jeżeli jej wartość to development, komponent jest widoczny:

{process.env.NODE_ENV === 'development' && <DevTool control={control} />}

Podsumowanie

Opisanie wszystkich zaawansowanych możliwości biblioteki react-hook-form wykracza daleko poza ramy tego posta. Biblioteka dostarcza chociażby zaawansowane hooki do obsługi tablic pól, pozwalające zapanować nad wydajnością.
Po przeczytaniu tego wpisu i przeanalizowaniu kodu masz doskonałe przygotowanie pod rozpoczęcie używania tej biblioteki w codziennej pracy.