Reactable Forms API

FormBuilders

Reactable form builders are inspired by Angular Reactive Forms.

Forms are built by composing configuration objects using the helper functions: control, group, and array.

Once composed, you initialize a form with the build function.


build

build is a factory function for creating a Form Reactable from a configuration object generated by control, group, or array.

You can also provide an optional RxFormOptions object to customize behavior.

Example

import { build, control, group, array, load } from "reactables";

// Simple form with a single control 
const rxSimpleForm = build(control(["John Doe"]));

// Form with array of controls
const rxEmailsForm = build(
  array({
    controls: [
      control(["john@example.com"]),
      control(["doe@example.com"]),
    ],
  })
);

// Form with nested group 
const rxProfileForm = build(
  group({
    controls: {
      firstName: control(["John"]),
      lastName: control(["Doe"]),
    },
  })
);

// Subscribe to the profile form
const [state$, actions, actions$] = rxProfileForm;

state$.subscribe(formState => console.log("Profile form state:", formState));
actions.updateValues({ controlRef: ["firstName"] , value: "Jane", });

control

Creates a form control configuration object for a single field.

You can initialize it either with a configuration object or with a short-form tuple for convenience.

Example

// Using short-form tuple
const firstNameControl = control(["John", ['required']]);

// Using object notation with validators
const ageControl = control({
  initialValue: 30,
  validators: ["required", "number"],
  normalizer: ["numbersOnly"]
});

Note: Validators are specified as strings here, but the actual functions must be provided via form options when initializing the form.

group

Creates a form group configuration object, which is a collection of controls, nested groups, or arrays.
The controls dictionary contains keys for each child control.

You can also define validators and async validators at the group level, which apply to the group as a whole.

Example

const profileGroup = group({
  controls: {
    firstName: control(["John", ["required"]]),
    lastName: control(["Doe"]),
    age: control([30, ["required", "number"]]),
  },
  validators: ["groupValidator"],
  asyncValidators: ["asyncGroupValidator"]
});

Note: Validators are specified as strings in the configuration; the actual functions must be provided via form options when initializing the form.

array

Creates a form array configuration, which is a list of controls, groups, or arrays.

Arrays are useful for repeated fields, such as multiple email addresses or phone numbers.

You can also define validators and async validators at the array level, which apply to the array as a whole.

Example

const emailsArray = array({
  controls: [
    control(["john@example.com", ["required"]]),
    control(["doe@example.com"]),
  ],
  validators: ["arrayValidator"],
  asyncValidators: ["asyncArrayValidator"]
});

Note: Validators are specified as strings in the configuration; the actual functions must be provided via form options when initializing the form.

load

load creates a Reactable form from a previously saved form state, allowing you to restore a form with existing values.
You can provide optional form options to customize behavior when loading.

Example

import { load } from "reactables";

// Load a saved form state from session storage
const savedState = JSON.parse(sessionStorage.getItem("profileForm") || "{}");
const rxLoadedForm = load(savedState);

const [state$, actions] = rxLoadedForm;

state$.subscribe(formState => console.log("Restored form state:", formState));

RxFormOptions

Options for customizing the behavior of a Reactable form.

You can provide additional action sources, custom reducers, validators, and other settings.

OptionDescription
reducersDictionary of custom reducers for implementing special form behavior. Use built-in FormReducers to safely update form state while maintaining parent/child validation integrity.
sourcesAn array of action observables the form should listen to.
providersSee RxFormProviders for supplying validator, async validator, and normalizer functions.
nameOptional name for the form.
debugLogs all form actions and state changes if true.

Example

const formOptions = {
  reducers: {
    customReset: (formReducers, state) => ({ ...state, reset: true }),
  },
  sources: [externalActions$],
  providers: rxProviders, // See RxFormProviders below
  name: "profileForm",
  debug: true
};

RxFormProviders

Provides reusable validators, async validators, and normalizer functions for form controls. Keys must match the names declared in controls created with control, group, or array.

KeyDescription
normalizersDictionary of functions that transform input values before storing them in state.
validatorsDictionary of synchronous validation functions to enforce rules on control values.
asyncValidatorsDictionary of asynchronous validation functions. Each must be an observable operator function that returns an Observable<FormErrors> object.

Example


const rxProviders = {
  normalizers: {
    trim: (value) => value.trim(),
    numbersOnly: (value) => value.replace(/\D/g, "")
  },
  validators: {
    required: (value) => !!value,
    email: (value) => /\S+@\S+\.\S+/.test(value)
  },
  asyncValidators: {
    uniqueUsername: (control$) =>
      control$.pipe(
        mergeMap(({ value }) =>
          from(nameService.validateName(value)).pipe(
            map((exists) => ({ uniqueUsername: exists }))
          )
        )
      )
  }
};

RxFormActions

Actions available to trigger state changes on Form Reactable.

updateValues

Updates the value of a control. For groups and arrays, only existing descendant controls are updated; otherwise it will throw an error.

Example

const rxForm = build(group({
  controls: {
    firstName: control(["John"]),
    lastName: control(["Doe"]),
    address: group({
      controls: {
        street: control(["123 Main St"]),
        city: control(["Toronto"])
      }
    })
  }
}));

const [state$, actions] = rxForm;

state$.subscribe((state) => {
  console.log("Form value:", state.root.value);
});

// Update top-level control
actions.updateValues({ controlRef: ["firstName"], value: "Jane" });
// Form value: { firstName: "Jane", lastName: "Doe", address: { street: "123 Main St", city: "Toronto" } }

// Update nested control
actions.updateValues({ controlRef: ["address", "street"], value: "456 Oak Ave" });
// Form value: { firstName: "Jane", lastName: "Doe", address: { street: "456 Oak Ave", city: "Toronto" } }

addControl

Adds a control to a form group at a specified key.

Example


const rxForm = build(group({
  controls: {
    name: control(["John Doe"])
  }
}));

const [state$, actions] = rxForm;

state$.subscribe((state) => {
  console.log("Form value:", state.root.value);
});

// Add a new control dynamically
actions.addControl({
  controlRef: ["email"],
  config: control(["john@example.com", "email"])
});
// Form value: { name: "John Doe", email: "john@example.com" }


pushControl

Adds a control to the end of a form array.

Example

const rxForm = build(array({
  controls: [control(["john@example.com"])]
}));

const [state$, actions] = rxForm;

state$.subscribe((state) => {
  console.log("Form value:", state.root.value);
});

// Push a new control into the array
actions.pushControl({
  controlRef: [],
  config: control(["doe@example.com"])
});
// Form value: ["john@example.com", "doe@example.com"]

removeControl

Removes a specified control from a group or array.

Example

const rxForm = build(group({
  controls: {
    firstName: control(["John"]),
    lastName: control(["Doe"])
  }
}));

const [state$, actions] = rxForm;

state$.subscribe((state) => {
  console.log("Form value:", state.root.value);
});

// Remove a control
actions.removeControl(["lastName"]);
// Form value: { firstName: "John" }

markControlAsPristine

Marks a control and all descendants as pristine, clearing any “dirty” state.

Example

const rxForm = build(group({
  controls: {
    profile: group({
      controls: {
        firstName: control(["John"]),
        lastName: control(["Doe"])
      }
    }),
    email: control(["john@example.com"])
  }
}));

const [state$, actions] = rxForm;

state$.subscribe((state) => {
  console.log("Form value:", state.root.value, "| dirty:", state.root.dirty);
});

// Update nested control → both the control and ancestors become dirty
actions.updateValues({ controlRef: ["profile", "firstName"], value: "Jane" });
// Console: Form value: { profile: { firstName: "Jane", lastName: "Doe" }, email: "john@example.com" } | dirty: true

// Mark the whole form group as pristine → clears dirty flag for all descendants
actions.markControlAsPristine([]);
// Console: Form value: { profile: { firstName: "Jane", lastName: "Doe" }, email: "john@example.com" } | dirty: false

markControlAsTouched

Marks a control and all ancestors as touched.

Optional markAll flag marks all descendants as touched as well (default false).

const rxForm = build(group({
  controls: {
    username: control(["JohnDoe"]),
    password: control(["secret"])
  }
}));

const [state$, actions] = rxForm;

state$.subscribe((state) => {
  console.log("Form status:", state.username.touched);
});

// LOGS: false

// Mark username as touched
actions.markControlAsTouched({ controlRef: ["username"]  });

// LOGS: true

markControlAsUntouched

Marks a control and all descendants as untouched, and updates ancestor touched status accordingly.

Example

const rxForm = build(control(["hello"]));

const [state$, actions] = rxForm;

state$.subscribe((state) => {
  console.log("Form status:", state.root.touched);
});

// Mark as untouched
actions.markControlAsUntouched([]);

// LOG: false

resetControl

Resets a control by removing it and rebuilding it with the original configuration.

Example

const rxForm = build(group({
  controls: {
    city: control(["Toronto"])
  }
}));

const [state$, actions] = rxForm;

state$.subscribe((state) => {
  console.log("Form value:", state.root.value);
});

// Update city
actions.updateValues({ controlRef: ["city"], value: "Vancouver" });
// Form value: { city: "Vancouver" }

// Reset city to original config
actions.resetControl(["city"]);
// Form value: { city: "Toronto" }

Helpers

getArrayItems

Given a controlRef for a form array and a Form, returns all the controls for the form array control. If the controlRef does not find a form array control an error is throw.

type getArrayItems = <T extends BaseForm<unknown> | Form<unknown>>(
  controlRef: ControlRef,
  form: T,
) => T extends BaseForm<unknown> ? BaseControl<unknown>[] : FormControl<unknown>[]

getAncestorControls

Given a controlRef a Form, returns all the ancestor controls including itself.

type getAncestorControls = <T extends BaseForm<unknown> | Form<unknown>>(
  controlRef: ControlRef,
  form: T,
  excludeSelf = false,
) => (T extends Form<unknown> ? FormControl<unknown> : BaseControl<unknown>)[]

getDescendantControls

Given a controlRef a Form, returns all the descendant controls including itself.

type getDescendantControls = <T extends BaseForm<unknown> | Form<unknown>>(
  controlRef: ControlRef,
  form: T,
  excludeSelf = false,
) => (T extends Form<unknown> ? FormControl<unknown> : BaseControl<unknown>)[]

getValueFromControlConfig

Reads a AbstractControlConfig and returns its initial value for the form.

type getValueFromControlConfig = <T>(controlConfig: AbstractControlConfig) => T

Interfaces

Form

Form state. Dictionary of FormControl(s) where the key is a period separated representation of the ControlRef tuple.

interface Form<T> {
  root: FormControl<T>;
  [key: string]: FormControl<unknown>;
}

FormControl


interface FormControl<T> {
  pristineValue: T;
  controlRef: ControlRef;
  value: T;
  dirty: boolean;
  touched: boolean;
  validatorErrors: FormErrors;
  key: string;
  asyncValidatorErrors: FormErrors;
  asyncValidateInProgress: { [key: string | number]: boolean };
  errors: FormErrors;
  valid: boolean;
  childrenValid: boolean;
  pending?: boolean;
  config: AbstractControlConfig;
}
PropertyDescription
pristineValueOriginal value of control. Use to determine if control is dirty.
controlRefControls ControlRef.
valueControl value.
touchedTouched status of control
validatorErrorsFormErrors from validators (non-async)
asyncValidatorErrorsFormErrors from async validators
errorsFormErrors validatorErrors and asyncValidatorErrors merged.
validValid status of control. Also checks descendants.
childrenValidValid status of direct child controls.
configOriginal config for form control

ControlRef

Control Reference represented as a tuple for the FormControl

FormErrors

Dictionary of errors for the control.

interface FormErrors {
  [key: string]: any;
}

ValidatorFn

Validator function that reads the value of the FormControl and returns a FormErrors object.

type ValidatorFn = (value: any) => FormErrors;

ValidatorFnAsync

Validator function takes in an BaseControl observable and returns a higher order observable Observable<Observabe<FormErrors>>.

type ValidatorAsyncFn = <T>(control$: Observable<BaseControl<T>>) => Observable<Observable<FormErrors>>;

FormReducers

Built in reducers which can be used to update the state of the form tree. Payload and behaviour is the same and described in RxActions;


interface FormReducers {
  updateValues: <T>(state: BaseFormState<T>, payload: UpdateValuesPayload<unknown>,
  ) => BaseFormState<T>;
  removeControl: <T>(state: BaseFormState<T>, payload: ControlRef) => BaseFormState<T>;
  pushControl: <T>(state: BaseFormState<T>, payload: PushControlPayload) => BaseFormState<T>;
  addControl: <T>(state: BaseFormState<T>, payload: AddControlPayload) => BaseFormState<T>;
  markControlAsPristine: <T>(state: BaseFormState<T>, payload: ControlRef) => BaseFormState<T>;
  markControlAsTouched: <T>(state: BaseFormState<T>, payload: MarkTouchedPayload) => BaseFormState<T>;
  markControlAsUntouched: <T>(state: BaseFormState<T>, payload: ControlRef,
  ) => BaseFormState<T>;
  resetControl: <T>(state: BaseFormState<T>, payload: ControlRef) => BaseFormState<T>;
}

CustomReducer


type CustomReducerFunc<FormValue = unknown> = (
  reducers: FormReducers,
  state: BaseFormState<FormValue>,
  action: any,
) => BaseFormState<unknown>;

type CustomReducer<FormValue = unknown> =
  | CustomReducerFunc<FormValue>
  | {
      reducer: CustomReducerFunc<FormValue>;
      effects?: Effect[] | ((payload?: unknown) => ScopedEffects);
    };

BaseFormState

Form state before it is fully validated. This is accessible in CustomReducers so developer can read the current state and implement custom form behaviours.

interface BaseFormState<T> {
  form: BaseForm<T>;
  _changedControls?: {
    [key: string]: BaseControl<unknown>;
  };
  _removedControls?: {
    [key: string]: BaseControl<unknown>;
  };
}

type BaseForm<T> = {
  root: BaseControl<T>;
  [key: string]: BaseControl<unknown>;
};

Configuration Interfaces

interface ValidatorConfigs {
  validators?: string[];
  asyncValidators?: string[];
}

export interface FormGroupConfig extends ValidatorConfigs {
  controls: { [key: string]: AbstractControlConfig };
}

export interface FormArrayConfig extends ValidatorConfigs {
  controls: AbstractControlConfig[];
}

export interface FormControlConfig<T> extends ValidatorConfigs {
  initialValue: T;
  normalizers?: string[];
}

export type AbstractControlConfig = (
  | FormControlConfig<unknown>
  | FormArrayConfig
  | FormGroupConfig
) & {
  controls?: AbstractControlConfig[] | { [key: string]: AbstractControlConfig };
};