Reactable Forms

Reactable Forms provide a reactable for managing the state of your form(s).

Inspired by Angular Reactive Forms, Reactable Forms take a different approach:
state is managed with reducers inside reactables rather than class-based form controls.


Installation

Reactable Forms require RxJS 6 or above.

# Core package (requires RxJS 6+)
npm i rxjs @reactables/core

# React bindings + form utilities
npm i @reactables/react @reactables/react-form

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 { useReactable } from '@reactables/react';
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 { map, delay } from 'rxjs/operators';

export const RxFormAsyncValidation = () =>
  build(
    group({
      controls: {
        email: control(['', ['required', 'email'], ['blacklistedEmail']]),
      },
    }),
    {
      providers: {
        asyncValidators: {
          blacklistedEmail: (control$) =>
            control$.pipe(
              map(({ 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 define custom reducers when initializing a form to add custom behavior.

In this example, the form reactable gets a doubleOrder action that doubles the donut 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: Note: Always update the form imperatively using FormReducers. This ensures all related controls are updated correctly, preserving 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;

Typed Forms

You can pass explicit type parameters when calling build to get stronger type inference for both your form state and your custom reducers. This ensures that state$, actions, and actions$ are all fully typed, reducing mistakes when using the form reactable.

Example

✏️ Edit this snippet

// Define the form's value type
type FormValue = {
  name: string;
};

// Define a custom reducer
const customReducers = {
  set: (
    { updateValues }: FormReducers,
    state: BaseFormState<FormValue>,
    action: Action<string>
  ) =>
    updateValues(state, {
      controlRef: ['name'],
      value: action.payload,
    }),
};

// Build the form with type parameters and custom reducers
const [state$, actions, actions$] = build<FormValue, typeof customReducers>(
  group({
    controls: {
      name: control(['']),
    },
  }),
  { reducers: customReducers }
);

// ✅ Typed:
// - state$: Observable<Form<FormValue>>
// - actions: { set: (payload: string) => void } & RxFormActions
// - actions$: typed stream with constants for RxFormActions & customReducers