Reactable Forms

Reactive forms with reactables. What fun is state management without forms?

Reactable Forms API took its inspiration from Angular Forms but has a functional reactive programming style. State is managed with reducers inside reactables vs class based form controls.

Installation

Requires RxJS 6 or above. If not already installed, run npm i rxjs

npm i @reactables/forms

Basic Form Group

Edit this snippet
import { control, build, group } from '@reactables/forms';

export const RxBasicFormGroup = build(
  group({
    controls: {
      name: control(['']),
    },
  })
);

Bind RxBasicFormGroup to View
Edit this snippet

See React Bindings & React Form Components for API reference!

Input.tsx

import { WrappedFieldProps } from '@reactables/react-forms';
const Input = ({
  input,
  label,
  meta: { touched, valid },
}: { label?: string } & WrappedFieldProps) => {
  return (
    <div className="mb-3">
      {label && (
        <label
          className={`form-label ${touched && !valid ? 'text-danger' : ''}`}
        >
          {label}
        </label>
      )}
      <input
        {...input}
        type="text"
        className={`form-control ${touched && !valid ? 'is-invalid' : ''}`}
      />
    </div>
  );
};

export default Input;


App.tsx


import './App.css';
import { useReactable } from '@reactables/react';
import { Form, Field } from '@reactables/react-forms';
import { RxBasicFormGroup } from './RxBasicFormGroup';
import Input from './Input';

function App() {
  const rxForm = useReactable(RxBasicFormGroup);
  return (
    <Form rxForm={rxForm}>
      <Field component={Input} name="name" label="Name: " />
    </Form>
  );
}

export default App;


Form Arrays

Edit this snippet
import { build, group, control, array } from '@reactables/forms';

export const userConfig = group({
  controls: {
    name: control(['', 'required']),
    email: control(['', ['required', 'email']]),
  },
});

export const RxFormArray = () => build(
  group({
    controls: {
      contacts: array({
        controls: [userConfig],
      }),
    },
  })
);

Bind RxFormArray to View
Edit this snippet

See React Bindings & React Form Components for API reference!


import { build, group, control, array } from '@reactables/forms';
import { useReactable } from '@reactables/react-helpers';
import { Form, Field, FormArray } from '@reactables/react-forms';
import Input from './Input';
import { RxFormArray, userConfig } from './RxFormArray';

const MyForm = () => {
  const [state, actions] = useReactable(RxFormArray);

  if (!state) return <></>;

  return (
    <Form rxForm={[state, actions]}>
      <FormArray name="contacts">
        {({ items, pushControl, removeControl }) => {
          return (
            <>
              {items.map((control, index) => {
                return (
                  <div key={control.key}>
                    <div>Contact # {index + 1}</div>
                    <Field
                      name={`contacts.${index}.name`}
                      label="Name:"
                      component={Input}
                    />
                    <Field
                      name={`contacts.${index}.email`}
                      label="Email: "
                      component={Input}
                    />
                    <button type="button" onClick={() => removeControl(index)}>
                      Remove contact
                    </button>
                  </div>
                );
              })}
              <button type="button" onClick={() => pushControl(userConfig)}>
                Add Contact
              </button>
            </>
          );
        }}
      </FormArray>
    </Form>
  );
};

export default MyForm;


Validation

Edit this snippet

@reactable/forms comes with 3 built in validators, required, email & arrayNotEmpty. The developer can implement their own ValidatorFns and provide them when building the reactable.

import { control, build, group } from '@reactables/forms';

export const RxFormValidation = () => build(
  group({
    controls: {
      donuts: control(['0', ['required', 'min4']]),
    },
  }),
  {
    providers: {
      validators: {
        min4: (value) => ({ min4: Number(value) < 4 }),
      },
    },
  }
);

Bind RxFormValidation to View
Edit this snippet

See React Bindings & React Form Components for API reference!

DonutInput.tsx

import { WrappedFieldProps } from '@reactables/react-forms';
const DonutInput = ({
  input,
  label,
  meta: { touched, errors, valid },
}: { label?: string } & WrappedFieldProps) => {
  return (
    <div className="mb-3">
      {label && (
        <label
          className={`form-label ${touched && !valid ? 'text-danger' : ''}`}
        >
          {label}
        </label>
      )}
      <input
        {...input}
        type="number"
        className={`form-control ${touched && !valid ? 'is-invalid' : ''}`}
      />
      {touched && errors.required && (
        <div>
          <small className="text-danger">Field is required</small>
        </div>
      )}
      {touched && errors.min4 && (
        <div>
          <small className="text-danger">Minimum of 4 donuts required</small>
        </div>
      )}
    </div>
  );
};

export default DonutInput;

App.tsx

import './App.css';
import { useReactable } from '@reactables/react';
import { Form, Field } from '@reactables/react-forms';
import { RxFormValidation } from './RxFormValidation';
import DonutInput from './DonutInput';

function App() {
  const rxForm = useReactable(RxFormValidation);
  return (
    <Form rxForm={rxForm}>
      <Field component={DonutInput} name="donuts" label="Number of Donuts: " />
    </Form>
  );
}

export default App;

Async Validation

FormControls have a pending: boolean state when their value changes and are awaiting the result from asynchronous validation.

Edit this snippet
import { control, build, group } from '@reactables/forms';
import { of } from 'rxjs';
import { switchMap, delay } from 'rxjs/operators';

export const RxFormAsyncValidation = () => build(
  group({
    controls: {
      email: control(['', ['required', 'email'], ['blacklistedEmail']]),
    },
  }),
  {
    providers: {
      asyncValidators: {
        blacklistedEmail: (control$) =>
          control$.pipe(
            switchMap(({ value }) =>
              of({
                blacklistedEmail: value === 'black@listed.com',
              }).pipe(delay(1000))
            )
          ),
      },
    },
  }
);

Bind RxFormAsyncValidation to View
Edit this snippet

See React Bindings & React Form Components for API reference!

Input.tsx

import { WrappedFieldProps } from '@reactables/react-forms';
const Input = ({
  input,
  label,
  meta: { touched, errors, valid, pending },
}: { label?: string } & WrappedFieldProps) => {
  return (
    <div className="mb-3">
      {label && (
        <label
          className={`form-label ${touched && !valid ? 'text-danger' : ''}`}
        >
          {label}
        </label>
      )}
      <input
        {...input}
        type="email"
        className={`form-control ${touched && !valid ? 'is-invalid' : ''}`}
      />
      {touched && errors.required && (
        <div>
          <small className="text-danger">Field is required</small>
        </div>
      )}
      {touched && errors.email && (
        <div>
          <small className="text-danger">Email invalid</small>
        </div>
      )}
      {touched && errors.blacklistedEmail && (
        <div>
          <small className="text-danger">Email is blacklisted</small>
        </div>
      )}
      {pending && <span>Validating...</span>}
    </div>
  );
};

export default Input;

App.ts

import './App.css';
import { useReactable } from '@reactables/react';
import { Form, Field } from '@reactables/react-forms';
import { RxFormAsyncValidation } from './RxFormAsyncValidation';
import Input from './Input';

function App() {
  const rxForm = useReactable(RxFormAsyncValidation);
  return (
    <Form rxForm={rxForm}>
      <Field component={Input} name="email" label="Email: " />
    </Form>
  );
}

export default App;



Normalizing Values

User input for a FormControl leaf (i.e having no child controls) can be normalized via normalizer functions provided during form initialization.

Edit this snippet
import { control, build, group } from '@reactables/forms';

const normalizePhone = (value) => {
  let input = value.replace(/\D/g, '').substring(0, 10); // First ten digits of input only
  const areaCode = input.substring(0, 3);
  const middle = input.substring(3, 6);
  const last = input.substring(6, 10);

  if (input.length > 6) {
    input = `(${areaCode}) ${middle} - ${last}`;
  } else if (input.length > 3) {
    input = `(${areaCode}) ${middle}`;
  } else if (input.length > 0) {
    input = `(${areaCode}`;
  }

  return input;
};

export const RxFormNormalizing = () => build(
  group({
    controls: {
      phone: control({
        initialValue: '',
        normalizers: ['phone']
      }),
    },
  }),
  {
    providers: {
      normalizers: {
        phone: normalizePhone,
      },
    },
  }
);

Bind RxFormNormalizing to View

Custom Reducers

You can declare CustomReducers during form initialization to implement custom behaviour.

Below the form reactable will have a doubleOrder action method which can be called to double the order amount.

Edit this snippet
import { control, build, group } from '@reactables/forms';

export const RxCustomReducers = () => build(
  group({
    controls: {
      donuts: control(['1', 'min4']),
    },
  }),
  {
    providers: {
      validators: {
        min4: (value) => ({ min4: Number(value) < 4 }),
      },
    },
    reducers: {
      doubleOrder:  (formReducers, state) => {
        /** Use built in Form Reducers for updating the form tree. **/
        const { updateValues } = formReducers;

        const orders = Number(state.form.donuts.value);
        const value = (orders * 2).toString();

        state = updateValues(state, { controlRef: ['donuts'], value });

  /**
   * You can perform any number of operations imperatively
   * with formReducers i.e addControl, removeControl etc...
   * until you get your desired result,
   * and then return the new state.
   **/

        return state;
      };,
    },
  }
);

IMPORTANT: When updating the form with custom reducers, it must be done imperatively with the provided FormReducers. This will propagate the change appropriately to all ancestor and descendant controls - maintaining the integrity of the state tree.

Bind RxCustomReducers to View
Edit this snippet

See React Bindings & React Form Components for API reference!

import './App.css';
import { useReactable } from '@reactables/react';
import { Form, Field } from '@reactables/react-forms';
import { RxCustomReducers } from './RxCustomReducers';
import Input from './Input';

function App() {
  const rxForm = useReactable(RxCustomReducers);
  const [, actions] = rxForm;
  return (
    <Form rxForm={rxForm}>
      <Field component={Input} name="donuts" label="Donuts: " />
      <button onClick={actions.doubleOrder}>Double the Order!</button>
    </Form>
  );
}

export default App;