ngrx-forms
Proper integration of forms in Angular 4 applications using ngrx.
Disclaimer: This library is not affiliated with the official ngrx library. I have created it mainly for my own use in one of my projects, but I thought others could profit as well.
There is an example app that showcases most of the functionality of this library.
Content
1 Installation
2 Design Principles
3 User Guide
4 Open Points
5 Contributing
1 Installation
npm install ngrx-forms --save
This library depends on versions ^4.0.0
of @angular/core
, @angular/forms
, and @ngrx/store
, and version ^5.0.0
of rxjs
.
2 Design Principles
This library is written to be as functional and as pure as possible. Most of the heavy lifting is done in pure reducer functions with the directives being only a thin layer to connect the form states to the DOM.
This library also tries to be as independent as possible from other libraries/modules. While there is of course a dependency on ngrx the touching points are small and it should be possible to adapt this library to any other redux library without too much effort. There is also a peer dependency on @angular/forms
from which we re-use the ControlValueAccessor
concept to allow easier integration with other libraries that provide custom form controls.
Conceptually this library borrows heavily from @angular/forms
, specifically the concepts of form controls and form groups (see the User Guide below for a more detailed description of these concepts).
3 User Guide
Getting Started
Import the module:
import { StoreModule } from '@ngrx/store';
import { NgrxFormsModule } from 'ngrx-forms';
import { reducers } from './reducer';
@NgModule({
declarations: [
AppComponent,
],
imports: [
NgrxFormsModule,
StoreModule.forRoot(reducers),
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Add a group state somewhere in your state tree via createFormGroupState
and call the formGroupReducer
inside your reducer:
import { Action } from '@ngrx/store';
import { FormGroupState, createFormGroupState, formGroupReducer } from 'ngrx-forms';
export interface MyFormValue {
someTextInput: string;
someCheckbox: boolean;
nested: {
someNumber: number;
};
}
const FORM_ID = 'some globally unique string';
const initialFormState = createFormGroupState<MyFormValue>(FORM_ID, {
someTextInput: '',
someCheckbox: false,
nested: {
someNumber: 0,
},
});
export interface AppState {
someOtherField: string;
myForm: FormGroupState<MyFormValue>;
}
const initialState: AppState = {
someOtherField: '',
myForm: initialFormState,
};
export function appReducer(state = initialState, action: Action): AppState {
const myForm = formGroupReducer(state.myForm, action);
if (myForm !== state.myForm) {
state = { ...state, myForm };
}
switch (action.type) {
case 'some action type':
return state;
default: {
return state;
}
}
}
Expose the form state inside your component:
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { FormGroupState } from 'ngrx-forms';
import { Observable } from 'rxjs/Observable';
import { MyFormValue } from './reducer';
@Component({
selector: 'my-component',
templateUrl: './my-component.html',
})
export class MyComponent {
formState$: Observable<FormGroupState<MyFormValue>>;
constructor(private store: Store<AppState>) {
this.formState$ = store.select(s => s.myForm);
}
}
Set the control states in your template:
<form novalidate [ngrxFormState]="(formState$ | async)">
<input type="text"
[ngrxFormControlState]="(formState$ | async).controls.someTextInput">
<input type="checkbox"
[ngrxFormControlState]="(formState$ | async).controls.someCheckbox">
<input type="number"
[ngrxFormControlState]="(formState$ | async).controls.nested.controls.someNumber">
</form>
Form Controls
Form controls in ngrx-forms are represented as plain state objects. Control states have the following shape:
export type FormControlValueTypes = string | number | boolean | null | undefined;
export interface ValidationErrors { [key: string]: any; }
export interface AbstractControlState<TValue> {
id: string;
value: TValue;
isValid: boolean;
isInvalid: boolean;
errors: ValidationErrors;
isEnabled: boolean;
isDisabled: boolean;
isDirty: boolean;
isPristine: boolean;
isTouched: boolean;
isUntouched: boolean;
isSubmitted: boolean;
isUnsubmitted: boolean;
}
export interface FormControlState<TValue extends FormControlValueTypes> extends AbstractControlState<TValue> {
isFocused: boolean;
isUnfocused: boolean;
lastKeyDownCode: number;
}
The following table explains each property.
Property | Negated | Description |
---|
id | | The unique ID of the form control. Usually this is the name of the field in the form value prefixed by the ID of the containing group, e.g. MY_FORM.someTextInput . |
value | | The value of the form control. Controls only support values of type string , number , boolean , null , and undefined to keep the state string serializable. |
isValid | isInvalid | The isValid flag is true if the control does not have any errors. |
errors | | The errors of the control. This property always has a value. If the control has no errors the property is set to {} . |
isEnabled | isDisabled | The isEnabled flag indicates whether the control is enabled. When isEnabled is false the errors are always {} (i.e. the control is always valid if disabled). |
isDirty | isPristine | The isDirty flag is set to true as soon as the value of the control changes for the first time. |
isTouched | isUntouched | The isTouched flag is set to true based on the rules of the underlying ControlValueAccessor (usually on blur for most form elements). |
isSubmitted | isUnsubmitted | The isSubmitted flag is set to true if the containing group is submitted. |
isFocused | isUnfocused | The isFocused flag is set to true if the control currently has focus. Note that this feature is opt-in. To enable it you have to add [ngrxEnableFocusTracking]="true" to your form element. |
lastKeyDownCode | | The lastKeyDownCode is set to the key code of the last key that was pressed on the control. Note that this feature is opt-in. To enable it you have to add [ngrxEnableLastKeydownCodeTracking]="true" to your form element. This feature can be used for example to react to Enter key events. Note that this feature is likely to be changed in the near future. |
Control states are associated with a form element via the NgrxFormControlDirective
(applied with [ngrxFormControlState]="controlState"
). This directive is reponsible for keeping the view and the state in sync. When the state is changed the update is always immediately sync'ed to the view.
ngrxUpdateOn
It is possible to control when view values changes are pushed to the state with the ngrxUpdateOn
attribute. The supported values are change
(pushed immediately when the view value changes; default) and blur
(pushed when the form element loses focus). Note that by changing the time value changes are pushed to the state you are also changing the time at which validation and other state updates that depend on the value happen.
Value Conversion
If you need to use a form element that only supports objects as values (e.g. most custom date picker components) you can provide a value converter via the ngrxValueConverter
attribute to perform a conversion between view and state values. Value converters are simple objects with two functions:
export interface NgrxValueConverter<TView, TState> {
convertViewToStateValue(value: TView): TState;
convertStateToViewValue(value: TState): TView;
}
ngrx-forms ships with a number of pre-made value converters. Currently these are defined as follows:
export const NgrxValueConverters = {
identity<T>() {
return {
convertViewToStateValue: value => value,
convertStateToViewValue: value => value,
} as NgrxValueConverter<T, T>;
},
dateToISOString: {
convertViewToStateValue: date => date === null ? null : date.toISOString(),
convertStateToViewValue: s => s === null ? null : new Date(Date.parse(s)),
} as NgrxValueConverter<Date | null, string | null>,
};
Below you can find a full example on how to use a value converter to work with dates as view values:
import { Action } from '@ngrx/store';
import { FormGroupState, createFormGroupState, formGroupReducer } from 'ngrx-forms';
export interface MyFormValue {
date: string;
}
const FORM_ID = 'some globally unique string';
const initialFormState = createFormGroupState<MyFormValue>(FORM_ID, {
date: new Date(0).toISOString(),
});
export interface AppState {
myForm: FormGroupState<MyFormValue>;
}
const initialState: AppState = {
myForm: initialFormState,
};
export function appReducer(state = initialState, action: Action): AppState {
const myForm = formGroupReducer(state.myForm, action);
if (myForm !== state.myForm) {
return { ...state, myForm };
}
return state;
}
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { FormGroupState, NgrxValueConverters } from 'ngrx-forms';
import { Observable } from 'rxjs/Observable';
import { MyFormValue } from './reducer';
@Component({
selector: 'my-component',
templateUrl: './my-component.html',
})
export class MyComponent {
formState$: Observable<FormGroupState<MyFormValue>>;
constructor(private store: Store<AppState>) {
this.formState$ = store.select(s => s.myForm);
}
dateValueConverter = NgrxValueConverters.dateToISOString;
}
<form novalidate [ngrxFormState]="(formState$ | async)">
<input type="date"
[ngrxFormControlState]="(formState$ | async).controls.date"
[ngrxValueConverter]="dateValueConverter">
</form>
Form Groups
Groups are collections of controls. Just like controls groups are represented as plain state objects. The state of a group is determined almost fully by its child controls (with the exception of errors
which a group can have by itself). Group states have the following shape:
export interface KeyValue { [key: string]: any; }
export type FormGroupControls<TValue> = {[controlId in keyof TValue]: AbstractControlState<TValue[controlId]> };
export interface FormGroupState<TValue extends KeyValue> extends AbstractControlState<TValue> {
controls: FormGroupControls<TValue>;
}
As you can see most properties are shared with controls via the common base interface AbstractControlState
. The following table explains each property in the context of a group.
Property | Negated | Description |
---|
id | | The unique ID of the group. |
value | | The aggregated value of the group. The value is computed by aggregating the values of all children. |
isValid | isInvalid | The isValid flag is true if the group does not have any errors itself and none of its children have any errors. |
errors | | The errors of the group. This property is computed by merging the errors of the control with the errors of all children where the child errors are a property of the errors object prefixed with an underscore (e.g. { groupError: true, _child: { childError: true } } ). If neither the group nor any children have errors the property is set to {} . |
isEnabled | isDisabled | The isEnabled flag is true if and only if at least one child control is enabled. |
isDirty | isPristine | The isDirty flag is true if and only if at least one child control is marked as dirty. |
isTouched | isUntouched | The isTouched flag is true if and only if at least one child control is marked as touched. |
isSubmitted | isUnsubmitted | The isSubmitted flag is set to true if the group is submitted. This is tracked by the NgrxFormDirective (which needs to be applied to a form via [ngrxFormState]="groupState" ). Note that applying this directive to a form prevents normal form submission since that does not make much sense for ngrx forms. |
controls | | This property contains all child controls of the group. As you may have noticed the type of each child control is AbstractControlState which sometimes forces you to cast the state explicitly. It is not possible to improve this typing until conditional mapped types are added to TypeScript. |
Group states are usually completely independent of the DOM (with the exception of root groups that are associated with a form
via NgrxFormDirective
). They are updated by intercepting all actions that change their children (i.e. the group's reducer is the parent reducer of all its child reducers and forwards any actions to all children; if any children change it recomputes the state of the group). A group state can be created via createFormGroupState
. This function takes an initial value and automatically creates all child controls recursively.
Dynamic Form Groups
Sometimes you will have to render a variable number of fields in your form. In such a case you can provide a form value interface that has an index signature and then add and remove controls dynamically. Instead of an index signature you can also use optional fields if the potential members of the form value are statically known. At runtime you can add and remove controls in two ways:
- explicitly call the
addControl
and removeControl
update functions (see the section below) - set the value of the form group via
setValue
which will automatically update the form group based on the value you provide
Below you can find an example of how this would look. Assume that we have an action that provides a variable set of objects which each should be mapped to a group with two form controls.
import { Action } from '@ngrx/store';
import { FormGroupState, setValue, cast } from 'ngrx-forms';
interface DynamicObject {
id: string;
someNumber: number;
someCheckbox: boolean;
}
interface DynamicObjectFormValue {
someNumber: number;
someCheckbox: boolean;
}
interface DynamicFormValue {
[id: string]: DynamicObjectFormValue;
}
interface SetDynamicObjectsAction extends Action {
type: 'SET_DYNAMIC_OBJECTS';
objects: DynamicObject[];
}
interface AppState {
someOtherState: string;
dynamicForm: FormGroupState<DynamicFormValue>;
}
export function appReducer(state: AppState, action: Action): AppState {
switch (action.type) {
case 'SET_DYNAMIC_OBJECTS': {
const newFormValue = (action as SetDynamicObjectsAction).objects.reduce((v, obj) => {
v[obj.id] = {
someNumber: obj.someNumber,
someCheckbox: obj.someCheckbox,
};
return v;
}, {} as DynamicFormValue);
const dynamicForm = cast(setValue(newFormValue, state.dynamicForm));
return { ...state, dynamicForm };
}
default:
return state;
}
}
Updating the State
All states are internally updated by ngrx-forms through dispatching actions. While this is of course also possible for you there exist a set of utility functions that can be used to update states. This is mainly useful to change the state as a result of a different action in your reducer. Note that ngrx-forms is coded in such a way that no state references will change if nothing inside the state changes. It is therefore perfectly safe to repeatedly call any of the functions below and the state will be updated exactly once or not at all if nothing changed. Each function can be imported from ngrx-forms
. The following table explains each function:
Function | Description |
---|
setValue | This curried function takes a value and returns a function that takes a state and updates the value of the state. Note that setting the value of the group will also update all children including adding and removing children on the fly for added/removed properties. Has an uncurried overload that takes a state directly as the second parameter. |
validate | This curried function takes a validation function as a parameter and returns a function that takes a state and updates the errors of the state with the result of the provided validation function applied to the state's value. Has an uncurried overload that takes a state directly as the second parameter. |
enable | This function takes a state and enables it. For groups this also recursively enables all children. |
disable | This function takes a state and disables it. For groups this also recursively disables all children. |
markAsDirty | This function takes a state and marks it as dirty. For groups this also recursively marks all children as dirty. |
markAsPristine | This function takes a state and marks it as pristine. For groups this also recursively marks all children as pristine. |
markAsTouched | This function takes a state and marks it as touched. For groups this also recursively marks all children as touched. |
markAsUntouched | This function takes a state and marks it as untouched. For groups this also recursively marks all children as untouched. |
markAsSubmitted | This function takes a state and marks it as submitted. For groups this also recursively marks all children as submitted. |
markAsUnsubmitted | This function takes a state and marks it as unsubmitted. For groups this also recursively marks all children as unsubmitted. |
focus | This function takes a control state and makes it focused (which will also .focus() the form element). |
unfocus | This function takes a control state and makes it unfocused (which will also .blur() the form element). |
setLastKeyDownCode | This function takes a control state and sets the last keydown code. |
addControl | This curried function takes a name and a value and returns a function that takes a group state and adds a child control with the given name and value to the state. |
removeControl | This curried function takes a name and returns a function that takes a group state and removes a child control with the given name from the state. |
These are very basic functions that perform simple updates on states. The last two functions below contain the real magic that allows easily updating deeply nested form states.
updateGroup
:
This curried function takes a partial object in the shape of the group's value where each key contains an update function for that child and returns a function that takes a group state, applies all the provided update functions recursively and recomputes the state of the group afterwards. As with all the functions above this function does not change the reference of the group if none of the child update functions change any children. The best example of how this can be used is simple validation:
import { updateGroup, validate } from 'ngrx-forms';
export interface NestedValue {
someNumber: number;
}
export interface MyFormValue {
someTextInput: string;
someCheckbox: boolean;
nested: NestedValue;
}
function required(value: any) {
return !!value ? {} : { required: true };
}
const updateMyFormGroup = updateGroup<MyFormValue>({
someTextInput: validate(required),
nested: updateGroup({
someNumber: validate(required),
}),
});
The updateMyFormGroup
function has a signature of FormGroupState<MyFormValue> -> FormGroupState<MyFormValue>
. It takes a state, runs all validations, updates the errors, and returns the resulting state.
In addition, the updateGroup
function allows specifying as many update function objects as you want and applies all of them after another. This is useful if you have dependencies between your update functions where one function's result may affect the result of another function. The following (contrived) example shows how to set the value of someNumber
based on the errors
of someTextInput
.
const updateMyFormGroup = updateGroup<MyFormValue>({
someTextInput: validate(required),
nested: updateGroup({
someNumber: validate(required),
}),
}, {
nested: (nested: AbstractControlState<NestedValue>, myForm: FormGroupState<MyFormValue>) =>
updateGroup<NestedValue>({
someNumber: (someNumber: AbstractControlState<number>) => {
if (myForm.controls.someTextInput.errors.required) {
return validate(() => ({}), setValue(1, someNumber));
}
return someNumber;
};
})(cast(nested))
});
groupUpdateReducer
:
This curried function combines a formGroupReducer
and the updateGroup
function by taking update objects of the same shape as updateGroup
and returns a reducer which first calls the formGroupReducer
and afterwards applies all update functions by calling updateGroup
. Combining all we have seen so far our final reducer would therefore look something like this:
const myFormReducer = groupUpdateReducer<MyFormValue>({
someTextInput: validate(required),
nested: updateGroup({
someNumber: validate(required),
}),
}, {
nested: (nested, myForm) =>
updateGroup<NestedValue>({
someNumber: someNumber => {
if (myForm.controls.someTextInput.errors.required) {
return validate(() => ({}), setValue(1, someNumber));
}
return someNumber;
}
})(cast(nested))
});
export function appReducer(state = initialState, action: Action): AppState {
const myForm = myFormReducer(state.myForm, action);
if (myForm !== state.myForm) {
state = { ...state, myForm };
}
switch (action.type) {
case 'some action type':
return state;
default: {
return state;
}
}
}
Custom Controls
As mentioned above ngrx-forms re-uses the ControlValueAccessor
concept of @angular/forms
. ngrx-forms ships its own variants of all default value accessors (most of which simply inherit the implementation from @angular/forms
. Most libraries providing custom value accessors should also work with ngrx-forms out of the box as long as they properly export the value accessor. However, in case a library does not do this you may have to write your own value accessor. See the example app for such a custom value accessor (in this case for the md-select
from @angular/material
which in version 2.0.0-beta.8
does not properly export the md-select
's value accessor).
4 Open Points
- providing a simple set of common validation functions (e.g. required, min, max, pattern, etc.) and error composition
- async validation (although already achievable via effects)
- providing some global configuration options (e.g. enabling focus tracking globally)
- add
isFocused
to groups to track whether any child is focused - some more tests for directives
- tests for example application
5 Contributing
Testing
The following command runs all unit tests:
npm test
Building and Packaging
The following command:
npm run build
- starts TSLint with Codelyzer
- starts AoT compilation using ngc compiler
- creates
dist
folder with all the files of distribution
To test the npm package locally run:
npm run pack-lib
and install it in an app to test it with:
npm install [path]ngrx-forms-[version].tgz
License
MIT
Copyright © 2017 Jonathan Ziller