Karel Žoha měl výbornou myšlenku, zkusit postavit formulářovou abstrakci nad novou knihovnou recoiljs. A já se chytil.

Motivace

V PriceFx máme hodně formulářů s hodně prvky a dost často máme formuláře ve formulářích nebo tabulkách. Taky máme hodně dat a formuláře na základě nich dynamicky vytváříme a měníme. A v neposlední řadě potřebujeme, aby to celé bylo pokud možno rychlé a efektivní.

Recoil

Nebudu lhát, z recoilu jsem absolutně nadšený. Snažit se o jakkoliv složitější async state management se zachováním výkonu pomocí hooků je podle mého názoru dost složitá úloha. S reaktivním recoilem spousta problémů prostě odpadá a navíc krásně hraje dohromady s reactem, a to především se Suspense. Za mě je to rozhodně ten missing piece.

Design

Formulářu v aplikaci může být mnoho, proto atomFamily namísto atom. Formulář má id a navázané fieldIds:

export const $form = atomFamily({
  key: `form`,
  default: (id) => ({ id, fieldIds: [] }),
});

Každý field je registrovaný zvlášť ve vlastním atomu. Díky tomu se formulář se zvyšujícím se počtem prvků při editaci nezpomaluje, ale naopak drží konstatní čas. Recoil totiž tak trochu obchází react a namísto updatu stavu někde výš ve VDOMu a následném rerenderu všech přidružených formulářových prvků, samotný field poslouchá na změnu jeho atomu nezávisle na zbytek formuláře a překreslí se tak jen a pouze on sám.

export const $field = atomFamily({
  key: "form_field",
  default: (id) => {
    // ...
  }
});

const Field = ({ formId, name }) => {
  const [fieldState, setFieldState] = useRecoilState(
    $field(`${formId}_${name}`)
  );
  // ...
}

Následují selectory, které lze všelijak skládat a získávat konkrétní, zajímavější formy dat:

export const $fields = selectorFamily({
  key: "form_fields",
  get: (formId) => ({ get }) => {
    const { fieldIds } = get($form(formId));
    return fieldIds.map((id) => get($field(`${formId}_${id}`)));
  },
});
export const $values = selectorFamily({
  key: "form_values",
  get: (formId) => ({ get }) => {
    return get($fields(formId)).reduce((acc, { name, value }) => {
      if (value) acc[name] = value;
      return acc;
    }, {});
  },
});

Tohle je typický default, ale umožňuje to dělat vlastní pohledy do složitějších formulářů, reagovat jen na změny konkrétních fieldů bez ohledu na zbytek formuláře apod. Až neskutečně jednoduše!

Async validace

Asynchronní validace, podobně jako jakýkoliv jiný async problém může být poměrně složitý s ohledem na konzistenci. Proto jsem přišel s následujícícm jednoduchým konceptem. Jde o to, že field je reprezentovaný mimo jiné těmito vlastnostmi:

type Field = {
  value: any,
  validator: async (value: any) => Error | null,
  validation: Promise<[name, (Error | null)]>,
  // ...
}

a hodnota se aktualizuje společně s validací, reprezentovanou jako promise, čímž je zaručeno, že hodnota je v syncu s výsledkem validace (a odpadá taková ta nepěkná manipulace s isValidating, error apod.):

const onChange = useCallback(
  ({ name, value }) => {
    setFieldState((state) => ({
      ...state,
      value,
      validation: state.validator(value),
    }));
  },
  []
);

Jak jsem již zmínil v úvodu, recoil ladí s reactem a vizuálně validaci můžeme vyřešit například takhle:

export function Validation({ formId, name }) {
  const [, error] = useRecoilValue($fieldValidation(`${formId}_${name}`));
  const style = { color: "red" };
  return error ? <span style={style}>{error}</span> : null;
}

<Suspense fallback="validating">
  <Validation formId={formId} name={name} />
</Suspense>

Můžete namítnout, že async validace jednoho fieldu není nijak složitý problém a máte pravdu. Ale zobrazit výsledek všech validací všech prvků někde nad formulářem, to už je jiná. S recoilem nic složitého:

// async selector, který získá validace ze všech prvků formuláře
export const $formValidation = selectorFamily({
  key: "form_validation",
  get: (formId) => ({ get }) => {
    const { fieldIds } = get($form(formId));
    const pairs = waitForAll(
      fieldIds.map((id) => $field(`${formId}_${id}`).validation)
    );
    return pairs.filter(([, error]) => error !== null);
  },
});

export function FormValidation({ formId }) {
  const validationPairs = useRecoilValue($formValidation(formId));
  return (
    <ul>
      {validationPairs
        .map(([name, error]) => (
          <li key={name}><span>{name}:</span>{error}</li>
        ))
      }
    </ul>
  );
}

<Suspense fallback="validating form">
  <FormValidation formId={formId} />
</Suspense>

Tím jsou validace vyřešené vizuálně, ale ještě bysme potřebovali na celkový výsledek počkat a podle toho se zařídit při odesílání... žádný problém, recoil myslí na vše:

const submit = useRecoilCallback(
  ({ snapshot }) => async () => {
    const values = await snapshot.getPromise($values(formId));
    const validationPairs = await snapshot.getPromise($formValidation(formId));

    await onSubmit({
      values,
      validation: validationPairs.reduce((acc, [name, error]) => {
        acc[name] = error;
        return acc;
      }, {}),
    });
  },
  [formId, onSubmit]
);

Závěr

Určitě recoilu dejte šanci. Minimálně koukněte na video.


19.6.2020 - příklady kódu upraveny na recoil verze 0.0.10 z původních pro verzi 0.0.8