Biblioteki do formularzy w React #4 – Final Form

Ten post jest ostatnim artykułem w serii o obsłudze formularzy w React:


Spis Treści

  1. Wprowadzenie
  2. Przykład prosty
    1. Walidacja
      1. Walidacja prosta
      2. Walidacja asynchroniczna
    2. Pełny kod przykładu prostego
    3. Repozytorium przykładu prostego na Github
  3. Przykład zaawansowany
    1. Problemy wersji 6.4.0 z walidacją asynchroniczną
    2. CSS GRID
    3. Dodawanie zdjęć do galerii za pomocą customowego komponentu
    4. Walidacja rozmiarów obrazka
    5. Pełny kod przykładu zaawansowanego
    6. Repozytorium przykładu zaawansowanego na Github
  4. Podsumowanie

Wprowadzenie

Biblioteka Final Form jest biblioteką bazującą na modelu subskrypcyjnym, pozwalającą subskrybować zmiany wartości, walidacji czy aktywności danego pola. Biblioteka nie jest związana z żadnych konkretnym frameworkiem, natomiast posiada wrapper dla Reacta – React Final Form i to on omówiony zostanie w tym artykule.

Dlaczego powinno zależeć Ci na poznaniu biblioteki Final Form? Ponieważ znacząco ułatwia takie rzeczy jak:

  • przekazywanie danych z i do formularza
  • skomplikowane, customowe walidacje
  • walidacja asynchroniczna
  • zarządzanie stanem formularza (by np pokazywać użytkownikowi których pól jeszcze nie „dotknął”)
  • podpinanie customowych komponentów do zautomatyzowanej logiki biblioteki

Przykład prosty

Zainstalujmy bibliotekę poleceniem:

npm install --save final-form react-final-form

Za pomocą komponentów Form i Field zbudujmy pierwszy przykład użycia biblioteki:

import React from 'react';
import { Form, Field } from 'react-final-form'

function App() {
  return (
    <Form
      onSubmit={data => alert(JSON.stringify(data))}
    >
      {
        ({
          handleSubmit
        }) => (
          <form onSubmit={handleSubmit}>
            <Field name="userName" component="input" />
            <button type="submit">
              Submit
            </button>
          </form>
        )
      }
    </Form>
  );
}

export default App;

Zarówno do komponentu Form jak i Field musimy przekazać to, co ma zostać wyrenderowane, mamy trzy możliwości w jaki sposób to zrobić:

  1. Za pomocą wspomnianego również w poprzednich postach wzorca FACC (Function As Child Component), gdzie jako props children przekazujemy funkcję, która dostanie od biblioteki parametry takie jak onSubmit.
  2. Przekazując taką samą funkcję do komponentu Form za pomocą propsa render.
  3. Za pomocą propsa component.

Walidacja

Walidacja prosta

Walidować możemy na dwóch poziomach, całego formularza bądź konkretnego pola. Jeżeli walidujemy na poziomie formularza, wtedy otrzymujemy obiekt zawierający wszystkie pola, i zwracamy również obiekt gdzie wiadomość błędu musi być pod takim samym kluczem jak nazwa pola:

<Form
  onSubmit={data => alert(JSON.stringify(data))}
  validate={data => {
    const errors = {}
    if (!data.userName) {
      errors.userName = 'Please provide user name'
    }
    return errors
  }}
>
//formularz
</Form>

Jeżeli walidujemy na poziomie pola, dostajemy wartość i jeżeli chcemy zaznaczyć błąd, zwracamy tekst który może zostać wyświetlony użytkownikowi:

<Field
  name="lastName"
  validate={value => !value && 'Please provide last name'}
>
  //input
</Field>

Błąd wyświetlić możemy w następujący sposób:

<Field
  name="lastName"
  validate={value => !value && 'Please provide last name'}
>
  {
    ({input, meta}) => (
      <>
        <input {...input} type="text" />
        {meta.error && meta.touched && <span>{meta.error}</span>}
      </>
    )
  }
</Field>

Od biblioteki dostajemy treść błędu poprzez props meta.error, dodatkowo przez meta.touched sprawdzamy czy formularz był już wysłany – by uniknąć pokazywania błędów zanim użytkownik wysłał formularz.

Walidacja asynchroniczna

Dodajmy znaną z poprzednich postów funkcję walidacji asynchronicznej wraz z symulacją zapytania na serwer:

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

const validateCaptcha = async value => {
  const valid = await sendRequest(value);
  return valid? undefined : 'Please provide valid captcha';
}

Podepnijmy walidację na pole captcha:

<Field name="captcha" validate={validateCaptcha}>
  //input
</Field>

Widzimy że z funkcji walidującej musimy zwrócić albo wartość undefined dla braku błędu lub treść błędu, albo funkcję które resolvuje się do undefined bądź treści błędu. Co istotne, wysyłanie formularza poczeka na zakończenie asynchronicznej walidacji i dopiero wtedy wykona akcję onSubmit.

Pełny kod przykładu prostego

Poniższy kod nadaje się do skopiowania i uruchomienia, oczywiście po zainstalowaniu zależności

Kliknij tutaj, by zobaczyć pełny kod
import React from 'react';
import { Form, Field } from 'react-final-form'

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

const validateCaptcha = async value => {
const valid = await sendRequest(value);
return valid? undefined : 'Please provide valid captcha';
}

function App() {
return (
<Form
onSubmit={data => alert(JSON.stringify(data))}
validate={data => {
const errors = {}
if (!data.userName) {
errors.userName = 'Please provide user name'
}
return errors
}}
>
{
({
handleSubmit
}) => (
<form onSubmit={handleSubmit}>
<Field name="userName">
{
({input, meta}) => (
<>
<input {...input} type="text" />
{meta.error && meta.touched && <span>{meta.error}</span>}
</>
)
}
</Field>
<br />
<Field name="lastName" validate={value => !value && 'Please provide last name'}>
{
({input, meta}) => (
<>
<input {...input} type="text" />
{meta.error && meta.touched && <span>{meta.error}</span>}
</>
)
}
</Field>
<br />
<Field name="captcha" validate={validateCaptcha}>
{
({input, meta}) => (
<>
<input {...input} type="text" />
{meta.error && meta.touched && <span>{meta.error}</span>}
</>
)
}
</Field>
<br />
<button type="submit">
Submit
</button>
</form>
)
}
</Form>
);
}

export default App;

Repozytorium przykładu prostego na Github

Kod możesz również uruchomić korzystając z repozytorium Github:

https://github.com/radek-anuszewski/react-final-form-simple-example


Przykład zaawansowany

W przykładzie zaawansowanym, jak w poprzednich postach, napiszemy prostą galerię zdjęć. Ponieważ, podobnie jak Formik, React Final Form nie wspiera plików wprost, napiszemy naszą własną obsługę.

Problemy wersji 6.4.0 z walidacją asynchroniczną

Ponieważ React Final Form w wersji 6.4.0 ma problemy z walidacją asynchroniczną: https://github.com/final-form/react-final-form/issues/780 użyłem wersji 6.3.5:

"react-final-form": "6.3.5",

Taka wersja ustawiona będzie w repozytorium przykładu na Github.

CSS GRID

Z poprzedniego postu wykorzystamy sposób wyświetlania obrazków w galerii:

<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 20%) 50px 3em 1fr',
gridTemplateRows: 'repeat(5, 20%)',
}}
>
{images.map(image => (
<div>
<strong>
{image.name}
</strong>
<br />
<img style={{maxWidth: '100%'}} alt="" src={image.file} />
</div>
))}
</div>

Stworzyliśmy siatkę 5 wierszy na 5 kolumn, gdzie mamy 2 kolumny zajmujące 20% przestrzeni, jedną zajmującą 50px, jedną zajmującą 3em i ostatnią zajmującą pozostałą przestrzeń.

Jeśli chcesz dowiedzieć się więcej o CSS GRID, polecam obejrzeć moją prezentację z webinaru Tarnów Devs:

W opisie filmu znajdziesz link do prezentacji na Google Drive oraz adres repo Githuba dla playgroundu którego użyłem w prezentacji.

Dodawanie zdjęć do galerii za pomocą customowego komponentu

Dużym ułatwieniem jest to, że do zarejestrowania komponentu wystarczy tylko podać name i referencję do naszego komponentu w propsie component. Biblioteka wspiera tablice – jeżeli chcemy by pole było elementem tablicy jego nazwę podajemy wraz z indeksem, przykładowo images[0]:

{ids.map((id, i) => (
<Field
key={id}
name={`images[${i}]`}
component={FormElement}
validate={validateImage}
/>
))}

Walidacja rozmiarów obrazka

Dzięki temu, że React Final Form wspiera walidację asynchroniczną, możemy użyć adresu pliku, poczekać aż się załaduje i wtedy zresolvować odpowiednią wartość:

const validateImageSize = async file => new Promise(resolve => {
const image = new Image()
image.onload = () => {
const valid = image.width <= 1000 && image.height <= 1000;
resolve(valid? undefined : 'Image size is at most 1000 x 1000')
}
image.src = file;
})

const validateImage = async image => {
if (!image) {
return;
}
if (!image.name || !image.file) {
return 'Specify image and file';
}
return validateImageSize(image.file);
}

Pełny kod przykładu zaawansowanego

Poniższy kod nadaje się do skopiowania i uruchomienia, oczywiście po zainstalowaniu zależności

Kliknij tutaj, by zobaczyć pełny kod
import React, {useEffect, useState} from 'react';
import { Form, Field } from 'react-final-form'

const FormElement = (props) => {
const [name, setName] = useState(null)
const [file, setFile] = useState(null)
useEffect(() => {
props.input.onChange({
name,
file,
});
//did mount, should run only once
// eslint-disable-next-line
}, [])
return (
<>
<input onChange={e => {
const name = e.target.value;
props.input.onChange({
name,
file,
});
setName(name);
}} />
<input type="file" onChange={e => {
const file = URL.createObjectURL(e.target.files[0]);
props.input.onChange({
name,
file,
});
setFile(file);
}} />
{props.meta.touched && props.meta.error && (
<div>
{props.meta.error}
</div>
)}
<br />
</>
)
}

const validateImageSize = async file => new Promise(resolve => {
const image = new Image()
image.onload = () => {
const valid = image.width <= 1000 && image.height <= 1000;
resolve(valid? undefined : 'Image size is at most 1000 x 1000')
}
image.src = file;
})

const validateImage = async image => {
if (!image) {
return;
}
if (!image.name || !image.file) {
return 'Specify image and file';
}
return validateImageSize(image.file);
}

function App() {
const [images, setImages] = useState([]);
const [ids, setIds] = useState([]);

return (
<>
<Form
onSubmit={data => {
setImages([...images, ...data.images]);
setIds([]);
}}
>
{
({
handleSubmit
}) => (
<form onSubmit={handleSubmit}>
{ids.map((id, i) => (
<Field
key={id}
name={`images[${i}]`}
component={FormElement}
validate={validateImage}
/>
))}
<button
type="button"
onClick={() => setIds([...ids, new Date().getTime()])}
>
Add image
</button>
<br />
<button type="submit">
Submit
</button>
</form>
)
}
</Form>
<p>
Images:
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 20%) 50px 3em 1fr',
gridTemplateRows: 'repeat(5, 20%)',
}}
>
{images.map(image => (
<div>
<strong>
{image.name}
</strong>
<br />
<img style={{maxWidth: '100%'}} alt="" src={image.file} />
</div>
))}
</div>
</p>
</>
);
}

export default App;

Repozytorium przykładu zaawansowanego na Github

Kod możesz również uruchomić korzystając z repozytorium Github:

https://github.com/radek-anuszewski/react-final-form-advanced-example


Podsumowanie

React Final Form jest bardzo popularnym, szeroko przetestowanym rozwiązaniem. Zaskoczeniem było dla mnie to, że występują problemy z asynchroniczną walidacją, jednak jak wynika z opisu zgłoszenia wiadomo co je powoduje. Stąd, gdy wszystko już będzie działać, biblioteka ta będzie godna polecenia dzięki łatwemu podpięciu customowych komponentów, uproszczeniu skomplikowanej walidacji czy obsłudze tablic.