Ten post jest kontynuacją serii o obsłudze formularzy w React:
Spis Treści
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:
- Użytkownik wpisuje złe dane, np test2
- Klika “Send”
- 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.