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 snippetimport { control, build, group } from '@reactables/forms';
export const RxBasicFormGroup = build(
group({
controls: {
name: control(['']),
},
})
);
Bind RxBasicFormGroup to View
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;
import { RxBasicFormGroup } from './RxBasicFormGroup';
const [state$, actions] = RxBasicFormGroup();
// Cache the DOM
const nameControlEl = document.getElementById('name-control');
// Bind Event Handlers
nameControlEl.oninput = ({ target: { value } }) => {
actions.updateValues({
controlRef: ['name'],
value,
});
};
nameControlEl.onblur = () => {
actions.markControlAsTouched({ controlRef: ['name'] });
};
// Subscribe to state updates and bind to view.
state$.subscribe((state) => {
const { name } = state;
nameControlEl.value = name.value;
});
Form Arrays
Edit this snippetimport { 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
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 ValidatorFn
s 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
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;
import { RxFormValidation } from './RxFormValidation';
const [state$, actions] = RxFormValidation();
// ...Cache the DOM and bind event handlers
// Subscribe to state updates and bind to view.
state$.subscribe((state) => {
const { donuts } = state;
donutControlEl.value = donuts.value;
const handleErrors = (el, show) => {
el.className = show ? 'form-error show' : 'form-error';
};
handleErrors(donuntMinOrderErrorEl, donuts.touched && donuts.errors.min4);
handleErrors(donuntRequiredErrorEl, donuts.touched && donuts.errors.required);
});
Async Validation
FormControl
s have a pending: boolean
state when their value changes and are awaiting the result from asynchronous validation.
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
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;
import { RxFormAsyncValidation } from './RxFormAsyncValidation';
const [state$, actions] = RxFormAsyncValidation();
// ...Bind Event Handlers
// Subscribe to state updates and bind to view.
state$.subscribe((state) => {
const { email } = state;
emailControlEl.value = email.value;
const handleErrors = (el, show) => {
el.className = show ? 'form-error show' : 'form-error';
};
emailPendingEl.className = email.pending ? 'show' : '';
handleErrors(emailRequiredErrorEl, email.touched && email.errors.required);
handleErrors(emailAsyncErrorEl, email.errors.blacklistedEmail);
});
Normalizing Values
User input for a FormControl
leaf (i.e having no child controls) can be normalized via normalizer functions provided during form initialization.
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
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 { RxFormNormalizing } from './RxFormNormalizing';
import Input from './Input';
function App() {
const rxForm = useReactable(RxFormNormalizing);
return (
<Form rxForm={rxForm}>
<Field component={Input} name="phone" label="Phone: " />
</Form>
);
}
export default App;
const rxForm = RxFormNormalizing();
const [state$, actions] = rxForm;
// Cache the DOM
const phoneControlEl = document.getElementById('phone-control');
const phoneRequiredErrorEl = document.getElementById('phone-required-error');
// Event Handlers
const onInput =
(controlRef) =>
({ target: { value } }) => {
actions.updateValues({
controlRef,
value,
});
};
const onBlur = (controlRef) => () => {
actions.markControlAsTouched({ controlRef });
};
// Bind Event Handlers
phoneControlEl.oninput = onInput(['phone']);
phoneControlEl.onblur = onBlur(['phone']);
// Subscribe to state updates and bind to view.
state$.subscribe((state) => {
const { phone } = state;
phoneControlEl.value = phone.value;
const handleErrors = (el, show) => {
el.className = show ? 'form-error show' : 'form-error';
};
handleErrors(phoneRequiredErrorEl, phone.touched && phone.errors.required);
});
Custom Reducers
You can declare CustomReducer
s 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.
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
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;
const [state$, actions] = RxCustomReducers();
// Cache the DOM
const donutControlEl = document.getElementById('donut-control');
const donuntMinOrderErrorEl = document.getElementById('donut-min-order-error');
const doubleOrderBtnEl = document.getElementById('double-order-btn');
// Event Handlers
const onInput =
(controlRef) =>
({ target: { value } }) => {
actions.updateValues({
controlRef,
value,
});
};
const onBlur = (controlRef) => () => {
actions.markControlAsTouched({ controlRef });
};
// Bind Event Handlers
doubleOrderBtnEl.onclick = actions.doubleOrder;
donutControlEl.oninput = onInput(['donuts']);
donutControlEl.onblur = onBlur(['donuts']);
// Subscribe to state updates and bind to view.
state$.subscribe((state) => {
const { donuts } = state;
donutControlEl.value = donuts.value;
const handleErrors = (el, show) => {
el.className = show ? 'form-error show' : 'form-error';
};
handleErrors(donuntMinOrderErrorEl, donuts.touched && donuts.errors.min4);
});