Home

Seksowne formularze w React – Formik i Typescript. Część 1 – formularz logowania

Formularze są jednym z trudniejszych tematów na frontendzie – przeplata się wiele logiki, którą trzeba obsłużyć na kilku płaszczyznach – dynamiczne pola, walidowanie wartości, translacje czy trzymanie stanu. Jak zrobić to dobrze?

Angular posiada świetny moduł Reactive Forms, który pozwala nam elegancko synchronizować stan formularza z widokiem. React jednak nie dostarcza żadnego rozwiązania. Oczywiście prosty formularz możemy łatwo zaimplementować np przy użyciu lokalnego stanu (czy hooka useState), możemy też zostawić nasze pola niekontrolowane, a reagować tylko na onSubmit formularza – i przez chwilę może nam to starczy.

Pamiętajmy jednak, że nowoczesne aplikacje posiadają zaawansowaną logikę formularzy właśnie na frontendzie – po co wysyłać dane na backend i zmuszać użytkownika do czekania? Lepiej zostawić dla API walidację względem bazy danych (czy email jest zajęty? czy nie przeszły niedozwolone znaki? czy hasło jest prawidłowe?), a na frontendzie stworzyć solidny kod, przekładający się zarówno na dobry UX, jak i DX.

Na co składa się solidny formularz, zarówno funkcjonalnie jak i względem kodu?

  • Utrzymanie stanu formularza, ustawić domyślne dane
  • Pokazywanie dynamicznych / opcjonalnych elementów, w zależności od stanu aplikacji (np zaznaczenie checkboxa otwiera sekcję)
  • Pokazywanie prawidłowych (i przetłumaczonych!) wiadomości błędów dla każdego pola
  • Pokazywanie globalnego błędu, gdy nie da się go zakwalifikować dla pola (np. gdy API zwróci 500, ciężko pokazać ten błąd pod polem hasła)
  • Reagowanie gdy użytkownik zaznacza lub wychodzi z pola, by pokazać walidację w odpowiednim momencie (nie pokazujemy jej od razu, lecz dopiero gdy użytkownik wyjdzie z pola lub spróbuje wysłać formularz)
  • Obsługa wysyłania formularza do API oraz zareagowania na efekt tego requestu
  • Ubsługa dodatkowego stanu, takiego jak isLoading czy isValidating, pokazać ładowanie
  • Obsługa dynamicznej ilości pól
  • Testowalność
  • I pewnie jeszcze wiele innych

Zamiast porywać się z motyką na słońce, zobaczmy jak zrobić to z użyciem biblioteki Formik (oraz napiszemy to w TypeScript, z którym Formik świetnie działa).

Formik to biblioteka Reactowa, która przenosi zarządzanie stanem formularza do swojej abstrakcji. Możemy użyć jej poprzez komponent <Formik />, lub – co ja zawsze polecam, używając HOC-a withFormik(). Dlatego uważam że lepsze jest to drugie rozwiązanie? Pozwala ono na łatwiejszą kompozycję oraz daje decoupling komponentów stanu i widoku.

Najpierw zadeklarujmy sobie podstawowy formularz – najprostszym przykładem będzie logowanie do aplikacji. Będziemy posiadać pola email, password i keep me logged. A więc nasz model może wyglądać następująco:

export interface IValues {
    email: string;
    password: string;
    keepLogged: boolean;
}

Co odpowiadać będzie dwóm polom tekstowym i jednemu checkboxowi.

Zaprojektujmy w takim razie prosty, bezstanowy komponent formularza:

export const LoginForm = () => (
    <form>
        <label for="login-form-email-input">Email:</label>
        <input id="login-form-email-input" type="email" name="email" />

        <label for="login-form-password-input">Password:</label>
        <input id="login-form-password-input" type="password" name="password" />

        <label for="login-form-keep-logged-input">Keep me logged:</label>
        <input id="login-form-keep-logged-input" type="checkbox" name="keepLogged" />

        <button role="submit" type="submit">Log In</button>
    </form>
);

I co dalej? Tutaj wchodzi Formik i jego propsy. Dlatego zadeklarujmy jakie propsy powinien dostać nasz formularz:

interface IOuterProps {
    onSubmit: (login: string, password: string) => Promise<any>;
}

interface ILoginFormProps 
    extends FormikProps<IValues>, IOuterProps {}

Na początku tworzymy zewnętrzne propy, które zostaną przyznane od rodzica – czyli jeżeli umieścimy formularz w komponencie LoginPage, to będzie to wyglądać mniej więcej tak:

import { LoginForm } from '...loginform.tsx';

const callApi = (login: string, password: string) => myApi.authorize(login, password);

export const LoginPage = () => (
    <div>
    // Kod login page
        <LoginForm onSubmit={ callApi } />
    </div>
);

Jak widać, z komponentu rodzica przekazujemy callback, który w założeniu ma zostać wykonany gdy wyślemy nasz formularz. Zwraca on Promise. W prawdziwym przykładzie, użylibyśmy prawdopodobnie efektu/akcji Reduxowej (by nie couplować widoku i serwisu ze sobą).

Skoro mamy już zewnętrzne API formularza, trzeba załatwić to „wewnętrzne”. Wróćmy więc do naszego komponentu formularza.

interface ILoginFormProps 
    extends FormikProps<IValues>, IOuterProps {}

export const LoginForm = (props: ILoginFormProps): React.SFC<ILoginFormProps> => (
 // ...
)

Ten kawałek kodu ląduje jako propsy, które musi dostać nasz formularz. Jeżeli wypisalibyśmy teraz zawartość props, nie dostalibyśmy oczekiwanych (po interfejsie) danych – dostarczy nam je HOC withFormik.

export const ControlledLoginForm = withFormik<ILoginFormProps, IValues>({
    mapPropsToValues: (props: ILoginFormProps) => ({
        email: '',
        password: '',
        keepLogged: false,
    }),
    handleSubmit: (
        values: IValues,
        formikBag: FormikBag<ILoginFormProps, IValues>,
    ) => {
        formikBag.props.onSubmit(values.email, values.password);
    },
})(LoginForm);

Teraz przeanalizujmy co się stało 🙂

Po pierwsze, withFormik<ILoginFormProps, IValues>() to nasz Higher Order Component, który jako typ generyczny otrzymuje zewnętrzne propsy (onSubmit – dla rodzica) oraz wartości, pomaga nam to w prawidłowym typowaniu.

Następnie wprowadzamy obiekt konfiguracyjny, który ma dwa wymagane pola – mapPropsToValues i handleSubmit.

Ten pierwszy musi zwrócić inicjalny stan naszego formularza – musi więc być on zgodny z IValues które zadeklarowaliśmy. Możemy użyć do tego obiektu propsów, jednak tutaj nie ma takiej potrzeby. Kiedy warto tego użyć? Np. możemy przekazać od rodzica (lub Reduxa) predefiniowane dane.

Metoda handleSubmit zostanie wywołana gdy użytkownik wyśle formularz. W pierwszym argumencie przekaże wartości formularza, spełniające interfejs IValues, a w drugim obiekt FormikBag, który posiada interfejs do obsługi formularza. Udostępnia on callback onSubmit przekazany przez propsy, który od razu możemy wywołać.

Minimalny konfig Formika za nami, pora spiąć go z widokiem. Obiekt props przekazany do komponentu LoginForm będzie teraz wypełniony helperami Formika.

export const LoginForm = ({values, handleSubmit, handleChange}: ILoginFormProps): React.SFC<ILoginFormProps> => (
    <form onSubmit={handleSubmit}>
        <label for="login-form-email-input">Email:</label>
        <input 
            id="login-form-email-input" 
            value={values.email} 
            onChange={handleChange} 
            type="email" 
            name="password" 
        />

        <label for="login-form-password-input">Password:</label>
        <input
            id="login-form-password-input" 
            value={values.email} 
            onChange={handleChange} 
            type="password" 
            name="password" 
        />

        <label for="login-form-keep-logged-input">Email:</label>
        <input 
            id="login-form-keep-logged-input" 
            value={values.keepLogged} 
            onChange={handleChange} 
            type="checkbox" 
            name="keepLogged" 
        />

        <button role="submit" type="submit">Log In</button>
    </form>
);

Za pomocą destructuringu obiektu, wyciągneliśmy z propsów trzy helperyvalues, handleSubmit i handleChange.

Ten pierwszy to obiekt z aktualnymi wartościami, spełniający nasz interfejs IValues. handleSubmit przypisujemy jako callback dla eventu onSubmit elementu <form>. Teraz Formik potrafi zareagować na wysłanie formularza (wykonując metodę z konfiguracji HOC-a).

handleChange z kolei przypiąć możemy do wszystkich kontrolek, które emitują event onChange, a więc na pewno nasze <input>y. Warto pamiętać, że Formik matchuje klucz w obiekcie IValues na podstawie atrybutu name. Da się to obejść używając manualnie API (setFieldValue, setValues), ale w pełni wystarczy nam przypisanie name.

I to tyle – już mamy działający formularz! Oczywiście możemy zrobić jeszcze dużo, ale podstawowa logika za nami.

W kolejnym artykule omówię bardziej zaawansowaną logikę, jednak zanim skończymy, powinniśmy napisać jakieś testy. Do dzieła!

Do napisania testu użyjemy świetnej biblioteki react-testing-library, która wspiera pisanie dobrych praktyk w testach.

import React from 'react';
import { cleanup, fireEvent, render, wait } from 'react-testing-library';
import { ControlledLoginForm } from './login-form.component';

// Funkcja helper, która nic nie robi :)
const asyncNoop = async () => {};

// RTL wymaga czyszczenia drzewa DOM po każdym teście
afterEach(cleanup);

describe('Login Form component', () => {
    /** 
    * Snapshot test pozwala nam sprawdzać czy nasze zmiany nie 
    * powodują nieoczekiwanych zmian w HTML
    */
    it('Should match snapshot', () => {
        const { container } = render(
            <ControlledLoginForm onSubmit={asyncNoop} />,
        );

        expect(container).toMatchSnapshot();
    });

    it('Submits form properly', async () => {
        const mockSubmit = jest.fn();

        const { getByRole, getByLabelText } = render(
            <ControlledLoginForm onSubmit={mockSubmit} />,
        );
        const emailInput = getByLabelText('Email:');
        const passwordInput = getByLabelText('Password:');
        const submitButton = getByRole('submit');

        fireEvent.change(emailInput, {
            target: {
                value: 'some@email.com',
            },
        });

        fireEvent.change(passwordInput, {
            target: {
                value: 'some-password-1234',
            },
        });

        fireEvent.click(submitButton);

        await wait(() =>
            expect(mockSubmit).toBeCalledWith(
                'some@email.com',
                'some-password-1234',
            ),
        );
    });
});

Krótkie omówienie. Napisaliśmy dwa testy. Jeden to snapshot, a drugi to sprawdzenie czy event onSubmit jest wywołany z prawidłowymi danymi.

Snapshot test pozwala nam niskim kosztem wygenerować stan drzewa HTML i zapisać je w pliku, który jest commitowany. Gdy będziemy dopisywać nowe funkcjonalności, będziemy porównywać pliki snapshotów i oceniać czy zmiany były pożądane. Przykładowo – dodanie nowego pola (jeśli je dodamy) zmodyfikuje nasz snapshot (prawidłowo) lecz test się wysypie (prawidłowo). Wtedy snapshot manualnie nadpisujemy. Jeśli jednak dodamy nowe pole, a nasz formularz np. zmieni nazwę klasy – oznacza to że coś poszło nie tak i musimy to sprawdzić. Sam jestem ogromnym fanem pisania snapshotów ze względu na ich minimalny koszt.

W kolejnym teście symulujemy wpisywanie wartości do pól, a następnie oczekujemy by przekazany (zamockowany) callback został wywołany z tymi wartościami. Wszystko powinno działać!

Zwróć uwagę, że testujemy ControlledLoginFom, a nie LoginForm. Sprawia to, że testom jest bliżej w stronę integracji niż unitów – i bardzo dobrze, bo są one pewniejsze! Nic jednak nie stoi na przeszkodzie, byśmy napisali także testy „gołegoLoginForma – musimy jednak pamiętać o dostarczeniu zamockowanych wszystkich propsów, o które dba Formik (values itd). Podejście używania HOC-a i trzymania formularza niezależnie, pozwala nam wymienić implementację stanu Formika (np napisać własną, użyć redux-form lub final-form). I to jest właśnie najfajniesze w Reakcie.

W następnym odcinku omówimy walidację, stay tuned!