Creating Functional Forms
Daniel Figueiredo and Renee Vrantsidis
The architecture of a software system is a description ofhow to decompose it into pieces,how those pieces interact,and how that decomposition enables and constrains further development.In this chapter,we will begin our exploration of the architecture of JavaScript applicationsby looking at how to use ideas borrowed from pure functional programmingto simplify one of the most common tasks in web development:form handling.At first glance these two topics have nothing to do with each other,but paradoxically,acting as if the state of our system cannot be changedactually makes its changes easier to manage.
The Problem
You have probably built web applications that use forms.They are easy enough to manage if you only have a few,or a few dozen,but manual approaches fall apart by the time you havehundreds of distinct forms,each of which can update the application's state in different ways.
Angular provides some good tools for building forms,but these are not enough by themselves in very large programs.In particular,NgForm
leaves state management entirely in the developer's hands,which quickly results in unmanageable client-side complexity:a modern single-page application (SPA) may have to keep track ofhundreds of pieces of information,any of which may need to be updated based on the user's actionsand kept in sync with permanent storage on the server.
As if that wasn't complicated enough,many of these interactions have to be done asynchronouslyin order to keep the user's FQ (frustration quotient) down.If,for example,a web page locks up for half a second every time the user types a single characterbecause it's fetching possible auto-completes from the server,the user will quickly take her business somewhere else.
Our problem is therefore this:
How should we manage client-side state in an asynchronous web application that uses forms?
Our solution is:
Use Redux to represent state as a sequence of snapshots,each of which is created in response to a single action.
Redux in a Hurry
Luckily for us,people who use pure functional programming languageshave been thinking about these issues for more than 30 years,and have developed some design patterns that we can usein conventional languages like JavaScript.A pure functional language is one in which data cannot be changed,or mutated,in place:once a value is defined,it is immutable.Rather than changing its state,a program written in a pure functional language creates an entirely new stateon which to operate.
This may seem wasteful:after all,if I want to paint one wall of a room,I don't have to build an entirely new roomthat is identical to the original except for the color of the wall in question.However,making state immutable has a lot of advantages when we are dealing with concurrency.In particular,systems are a lot easier to reason about and test if they can't change under our feet.Pure functional programming therefore lets us substitutea cheap, plentiful resource–computer time–for one which is much more expensive–human brain power.
As we will see below,we can often avoid the need to copy all of the state.If we divide it into logically-separate chunks,we can re-use the chunks that don't change.In our experience,the amount of data that actually has to be duplicatedwill grow slowly with the size of the applicationso long as we think carefully about how to organize it.
The system we will use to illustrate this idea is Redux,which is built on three architectural principles:
There is a single source of truth,which in practice means thatthe entire state of the application is stored in a single object tree.A good rule of thumb is that this object tree must holdeverything needed to restore the state of the system after shutdown and restart.Storing all this information in one place makes debugging a lot easier,and as we shall see,also simplifies implementation of things like undo/redo.
State is read-only,i.e.,the object tree mentioned above is never modified in place.Instead,the only way to change the state is to create an action objectthat describes what change is desired.Button click handlers and I/O callbacks never update the state themselves,but rather create an action and queue it up to be handled sequentially.As a bonus,recording the state changes as objects makes replay, debugging, and testinga lot simpler.
Changes are made by pure functions.Redux calls these functions reducers,since they reduce the combination of an existing state and an actionto a new state.Reducers never have side effects:they do not modify global variables,write data to disk,or do anything else to change the world around them.This allows programmers to think about their effects one at a time,and makes it easy to combine and re-use them.
As a very short example,suppose we want to implement a traffic lightthat switches state between red, amber, and green.The state is an object with a single field showing the current color of the light:
let state = {color: 'red'};
and the reducer cycles between colors:
function changeColor(state, action) {
switch(action.type) {
case 'NEXT':
if (state.color == 'red')
return {color: 'green'};
else if (state.color == 'green')
return {color: 'amber'};
else if (state.color == 'amber')
return {color: 'red'};
default:
return state;
}
}
It's very important that changeColor
returns the original state without modificationwhen it doesn't recognize the type of the action it is being asked to perform,since this allows us to combine reducer functionswithout worrying that they will trip over one another.And to simplify our program,we frequently fold the initialization of the state into the definition of the reducerby defining the initial state as the default value for the reducer's first parameter:
function changeColor(state = {color: 'red'}, action) {
switch(action.type) {
...as before...
}
}
Once this reducer is defined,our application wraps it up to create an object store,and then sends actions to that store to tell it when to move to the next state:
import { createStore } from 'redux';
let store = createStore(changeColor);
for (let i=0; i<10; i++) {
store.dispatch({type: 'NEXT'});
console.log(store.getState().color); // red, green, amber, red, ...
}
If we want to add new capabilities,we simply update our reducer:
function changeColor(state, action) {
switch(action.type) {
case 'NEXT':
...as before...
case 'EMERGENCY':
return {color: 'red'};
default:
return state;
}
}
This may seem like a lot of work to manage a single traffic light,but that work pays off as soon as we have to worry about asynchronous updates.For example,if we want to do an emergency test of the light every night at 1:00 am,all we have to do is this:
let delay = until(tomorrow() + ONE_HOUR);
setTimeout(() => changeColor(state, {type: 'EMERGENCY'}), delay);
When the timeout callback is triggered,Redux will put the traffic light in the required stateregardless of what else has gone on or is going on.
Setting Up Forms to Work with Redux
As our running example,we will build a set of forms that allow users to create a wizard for a role-playing game.We created this example for a talk at NG Conf 2017,and the complete source is available on GitHub.
Our overall goals are to take advantage of Redux state managementto automatically store our form data in state as form values change,and if possible to use one mechanism to manage all the forms in our application.The first step is to plan the shape of our forms.We can structure the store however we want,but since we know that real applications evolve,we will define things using TypeScript interfacesrather than relying on specific concrete classes.That will make it easier to swap things in and out for testing later on.(It also encourages us to avoid using this
,which in turn encourages us to think more functionally.)
export interface IForm {
character: ICharacter; // our single top-level object (for now)
}
export interface ICharacter {
name?: string; // optional name
bioSummary: IBioSummary; // biographical information (see below)
skills: string[]; // list of skills
}
export interface IBioSummary {
age: number; // age in years
alignment: string; // good or bad, lawful or chaotic
race: string; // character's species
}
These interfaces define the shape of a form in our Redux store,which is the part of the store our reducer will be concerned with in this example.Defining these interfaces does not actually create the store–we will do that in a moment–butdoes specify the shape of the objects our reducers will hand back to us.
The form contains a single object representing a character,rather than using the character itself as the store.That way,if we want to add other top-level items in future,we won't need to reorganize existing material.For example,the next version of our application might have this structure:
export interface IForm {
character: ICharacter; // character info
equipment: IEquipment; // character's equipment
}
Equally,if we want to add more forms,we won't need to reorganize the existing material,and we can re-use common actions that work for any form.
One consequence of this decision is thatevery action should contain the path to the particular part of the state it applies to.Agile purists might say that we shouldn't add this until we need it,and if our application consisted of just a login form and a message of the day,they'd be right.However,we have enough experience with applications of this kind to know that we're going to,and adding that to our architecture from the start will save us re-work later.
Having decided on the structure of our store,the next step is to define its initial state.We will do this by creating a literal object that conforms to the ICharacter
interface,and another that conforms to the IForm
interface:
const characterInitialState: ICharacter = {
name: undefined,
bioSummary: {
age: undefined,
size: undefined,
alignment: undefined,
race: undefined,
},
skills: []
};
const initialState: IForm = {
character: characterInitialState
};
We could fold the definition of characterInitialState
into initialState
to create one large literal object,but again,since we're likely to add more state information(like the equipment the character is carrying),we have decided that initialState
will only ever bea list of top-level objects conforming to separately-defined interfaces.
We have defined our initial state in a file of its ownto make it easier to find and update.In a real application,it would probably be created programmaticallyso that we could swap in something else for testing.Note that in real life, it would not come from a database:instead, it's the blank slate that would be updated from a databaseas the application is being bootstrapped.(And yes,that bootstrap process would be implemented asa series of update actions.)However the initial state is created,it should be as empty as possible.
Another early decision is that we will define functions to create actions,and that each action will have two elements:a string called type
(which Redux requires),and a sub-object called payload
that will containthe path to the part of the store it applies toand whatever extra values are needed to update the state.Redux doesn't mandate this,but again,experience teaches us that creating literal objectsin many different places throughout our programis just as risky as using magic numbersrather than defining a constant and referring to it.
The nametype
is a requirement:Redux mandates it by exporting anAction
interface defined as:The name
interface Action { type: string }
payload
is not required, but is widely used and strongly encouraged,since in some cases we need to add extra info for our actions to be completed:
interface PayloadAction extends Action { payload: any }
As an example of an action creation function,here's one that saves a form's value:
const saveForm = (path, value) => ({
type: 'SAVE_FORM',
payload: {
path: path,
value: value
}
});
Now that we know the shapes of our store and our action,the first version of the reducer is easy to write.To keep it short,we rely on three helper functions from Ramda,a library of functional utilities for JavaScript:
- assocPath: makes a shallow clone of an object,replacing the specified property with the specified value as it does so.(Think of this as "make me a copy of X, but with Y set to Z".)
- merge: create a shallow copy of one object with properties merged in from a second object.(This is like assocPath, but using a second object to get multiple changes at once.)
- path: retrieve the value at a specified location in a structured object.
(We could write replacements for these to avoid depending on Ramda,but we prefer to leverage the work they've put into testing and performance optimization.)With these helpers in hand,ourformReducer
looks like this:
import { path, assocPath, merge } from 'ramda';
export function formReducer(state = initialState: IForm, action) {
switch (action.type) {
case 'SAVE_FORM':
return assocPath(
action.payload.path,
merge(path(action.payload.path, state), action.payload.value),
state
);
default:
return state;
}
}
There is actually less going on here than first appears.Working from the outside in,if the action's type is not SAVE_FORM
,then formReducer
returns the original state without any changes.This means that we can safely chain this reducer togetherwith others that handle other parts of our user interface:as long as they all use distinct keys for their actions,they will watch the state fly by without doing anything we don't want them to.
If the action does have the right type,formReducer
uses path
to get the part of the state that needs to be updated,then uses merge
and assocPath
to create a shallow copy of the state,replacing that element,and only that element,with a different value.The most important word in the previous sentence is "create":at this point,the state that was passed in is thrown away and a new one created.Some parts of the old state are recycled–assocPath
does a shallow clone–butthose are the parts that weren't changed,and if other reducers do their jobs properly,they will replace those parts rather than updating them in placewhen it's their turn to act.
Connecting the Dots
It's now time to turn our attention to the interfacethat will trigger this state changeand reflect any changes to the state triggered by other interface components.Since we're using Angular 2,we will define a classand use the @Component
decorator to weld some metadata to it:
@Component({
selector: 'character-form',
template: require('./character-form.html')
})
export class CharacterForm {
@ViewChild(NgForm) ngForm: NgForm;
public characterForm: IForm;
private formSubs;
constructor(private ngRedux: NgRedux<IAppState>) {}
}
There's a lot going on here,so we'll start with a high-level explanation and then dive into details.As the diagram shows,Angular will keep ngForm
and characterForm
in sync with each otherusing its own dark magic,which we will see in a moment.In order to synchronize that with our state,we create a standard observer/observable connectionto make characterForm
watch the character portion of our Redux state.
Looking once more at the diagram,there is a potential circularity in the updates.Angular avoids an infinite loop by breaking the cyclewhen characterForm
and the form's actual internal data are the same.
Looking more closely at the code used to create all of this:
- The @Component decorator in the code above tells Angular thatthis class is used to fill in
elementsin the character-form.html template snippet. - Using the @ViewChild decorator on ngFormgives this class an instance variable that watches a form.We need this because we are going to subscribe to event notifications from that formlater on.
- The characterForm instance variable is our working copy of the form's state.
- Finally, private ngRedux: NgRedux
triggers Angular's dependency injectionand gives us access to the Redux store.When our application is busy doing other things,our data will live in this store,and when we're testing,we can inject a mock object here to give us more insight.
TheformSubs
instance variable is the odd one out in this class.Its job is to store the observer/observable subscriptionconnecting our state to our formso that we can unsubscribe cleanly when this component is destroyed.Angular 2 will automatically unsubscribe on our behalf,but it's always safer to put our own toys away when we're done playing with them…
Since CharacterForm
is an Angular component,it needs a template to define how it will be rendered.In keeping with Angular best practices,we will use a template-driven formand bind its inputs to objects retrieved from the Redux state.The first part of that form looks like this:
<form #form="ngForm">
<label for="name">Character Name:</label>
<input
type="text"
name="name"
[(ngModel)]="characterForm.name">
<label for="age">Age:</label>
<input
type="number"
name="age"
[(ngModel)]="characterForm.bioSummary.age">
</form>
ngModel
is a magic word meaning "the value of this field in the form".The expression [(ngModel)]
therefore makes a form controland binds it to the public data member of our form object.The final step in wiring all of this together is therefore to subscribe to the Redux stateso that whenever it changes,the changes are automatically pushed into the form(which triggers a DOM update and a re-rendering of the page).Equally,since we're listening to the form for changes and writing those to our stateby creating and dispatching actions,anything the user does will be reflected in the state.The beauty of this is that if anyone else does an update anywhere,everything will keep itself in sync.
For the sake of simplicity we are synchronizing the whole form object in a single action,which sets the character attribute in the state with a brand new object every time.We could instead sync specific attributes to improve performanceby using thepath
mechanism introduced earlier.
It's tempting to put this final piece of wiring in the constructor of our component,but that doesn't workbecause Angular has to construct several objectsbefore any of them can be wired together.This is a key feature of Angular's architecture(and of the architectures of many other systems):object construction and object initialization are handled separatelyto free us from headaches related to cyclic references.
The right place to connect everything is ngOnInit
,which is called after all the objects in the system have been createdbut before any of them are used.The ngOnInit
for CharacterForm
does the two pieces of wiring described abovein an arbitrary order:
ngOnInit() {
// Subscribe to the form in state.
this.formSubs = this.ngRedux.select(state => state.form.character)
.subscribe(characterFormState => {
this.characterForm = characterFormState;
});
// Dispatch an action when the form is changed.
this.ngForm.valueChanges.debounceTime(0)
.subscribe(change =>
this.ngRedux.dispatch(
saveForm(
change,
['character']
)
)
);
}
The first half of this method says that when the state changes,we want to update the form.We use ngRedux.select
with a fat arrow function as callbackto pick out the character portion of the state,then subscribe to that with a another fat arrow callbackthat updates the form data(which as explained above triggers redisplay).
The second half of ngOnInit
does the binding in the opposite direction:whenever the form changes,we dispatch an action created by saveForm
that has SAVE_FORM
as the changeand ['character']
as the path to the part of the state we want to modify.ngForm
's valueChanges
method automatically gives us a change
valuethat contains all of the form values.These values arrive in a JavaScript object that mirrors the structure of the HTML,which is exactly what we want(because we defined the name attributes to get it).
And that's it:every change to our form triggers creation of a new state,and every change to our state triggered by anything elseis immediately reflected in our form.We aren't saving the state to disk,but that's best left to some kind of middleware(which can be smart about only persisting the bits of statethat have actually changed between updates).
Filling in the Gaps
The only meaningful way to assess an architectural decision is to askwhether it makes things comprehensible todayand easy to change tomorrow.Given all of the plumbing introduced above,you might think that our architecture falls short on both counts,but it turns out that it actually simplifies application evolution.To see this,let's have a look at how it helps us handle multi-valued forms,which Angular doesn't support out of the box.
As the name suggests,a multi-valued form is one that can have many values at once,such as a multi-select dropdown list.The natural way to store these values is in an array,such as the one shown below for storing a character's skills:
character: {
skills: ['Drinking', 'Knowing Things'],
}
The first step in adding support for multi-valued fields to our applicationis to define actions that manipulate arrays–or more precisely,to define functions that create actions that tell Reduxhow to generate a new array from an old one:
const addIntoArray = (path, value) => ({
type: 'ADD_INDEXED_FORM_VALUE',
payload: { path, value }
});
const putInArray = (path, value, index) => ({
type: 'UPDATE_INDEXED_FORM_VALUE',
payload: { path, value, index }
});
const removeFromArray = (index, path) => ({
type: 'REMOVE_INDEXED_FORM_VALUE',
payload: { index, path }
});
There's nothing exciting going on here:each function creates an action with a type
(again, required by Redux)and a payload
(our own term).To cut down on typing,we make use of the fact that {a, b}
is a shorthand for{a: a, b: b}
:
let a = 'A';
let b = 'B';
let both = {a, b};
console.log(both);
{a: 'A', b: 'B'}
The payload of the action returned by addIntoArray
therefore has two keys called path
and value
,which are in turn bound to whatever was passed infor the parameters with the corresponding names.
Once we have these actions,the next step is to update our reducer by adding the following case:
case 'ADD_INDEXED_FORM_VALUE':
const lensForProp = lensPath(action.payload.path);
const propValue = <any[]> view(lensForProp, state);
return assocPath(
action.payload.path,
concat(propValue, [action.payload.value]),
state
);
Again,we're using Ramda functions to create a new array of skillsgiven an old array and the thing we want to add:lensForProp
is the location of the skills array in our state,propValue
is its current value,and concat
wrapped up in assocPath
gives us the old skillswith the new one appended.The additions to handle updating and removing skills have a similar shape.
Extending our user interface is equally easy.We start with a helper function that creates and dispatches the required action:
addSkill() {
this.ngRedux.dispatch(addIntoArray({
value: undefined,
path: [ 'character', 'skills' ]
}));
}
We have put this in the form component,but neither Angular nor good design principles strictly require this:we could have put it (and similar functions) in a file full of utilities.
Now that we have the functions we need,we can write the HTML needed to put everything in front of the user:
<label>Skills:</label>
<div *ngFor="let skillSlot of characterForm.skills; let i = index;">
<select
[value]="skillSlot"
(change)="onSelectSkill($event, i)">
<option *ngFor="let skill of skills" [value]="skill">
{{ skill }}
</option>
</select>
<button type="button" (click)="removeSkill(i)">Remove</button>
</div>
<button type="button" (click)="addSkill()">Add skill</button>
The last line of this HTML is the one that lets users add skills;the rest is to handle skill display and removal.
The Payoff: Validation
One way to see how these changes pays off is to look at validation,and in particular at the way in which we can use selectors to compute data by composing functions,and then use memoization to make state changes more efficient.
For those who wish to be more precise,we should distinguish between selectors as a general conceptand selectors as implemented by thereselect
library that we're using.The memoization benefits come fromreselect
,but the general benefit of using a selector for validation is thatyou can derive the validity of your form from the data you already havewithout having to dispatch extra actions likeVALIDATE_FIELD
.
Selectors chain functions togetherand pipe their return values into the last function in the chain.In the simple code below,the selector will return form.character
:
const formStateSelector = (state: IAppState) => state.form;
const characterFormSelector = createSelector(
formStateSelector,
(form: IForm) => form.character
);
With that in hand,let's write a validation function that checks that all required fields are presentand add it to our component:
export const isFormValid = createSelector(
characterFormSelector,
character => character.name
&& character.bioSummary.age
&& character.skills.length > 0
);
@select(isFormValidSelector)
isFormValid$: Observable<boolean>;
Now,after selecting,we can use isFormValid$
with an asynchronous pipe in our template:
<button
[disabled]="!(isFormValid$ | async)"
type="submit">
Save
</button>
(The $
on the end of isFormValid$
is a naming convention used in the RxJS library and elsewhere meaning,"This is an observable."We don't have to use it,but we find it helps make code easier to understand.)
We can now go ahead and create specific rules for specific fields of our formusing a helper function called isBetweenNumber
that does exactly what you'd expect:
const humanAgeValid = isBetweenNumber(14, 40);
const elfAgeValid = isBetweenNumber(80, 800);
const tieflingAgeValid = isBetweenNumber(35, 53);
const bioSummarySelector = createSelector(
characterFormSelector,
({bioSummary}: ICharacter) => bioSummary
);
const ageValidationSelector = createSelector(
bioSummarySelector,
(bioSummary: IBioSummary) => {
switch (bioSummary.race) {
case 'Human': return humanAgeValid;
case 'Elf': return elfAgeValid;
case 'Tiefling': return tieflingAgeValid;
}
}
);
export const isAgeValidSelector = createSelector(
bioSummarySelector,
ageValidationSelector,
({age}, isAgeValid) => isAgeValid(age)
);
Once we have a pile of validation rules for our fields,we can chain those selectors together like this:
const isFormValidSelector = createSelector(
isAgeValidSelector,
isNameValidSelector,
(ageValid, nameValid) => ageValid && nameValid
);
In larger applications,we can and should go furtherand make selectors out of reusable validation functions:
const maxStringLengthValidation = (value: string, max: number) =>
value.length < max;
const minStringLengthValidation = (value: string, min: number) =>
value.length > min;
const isNameValidSelector = createSelector(
createFormFieldSelector(['character', 'name']),
(name: string) => maxStringLengthValidation(name, 50)
&& minStringLengthValidation(name, 3)
);
We can make our application more user-friendlyby subscribing to the selectors we've made and using them in our template:
@select(isAgeValidSelector)
isAgeValid$: Observable<boolean>;
@select(isNameValidSelector)
isNameValid$: Observable<boolean>;
Since we have access to Angular's FormControl
states,we can use them to show error messages:
<input
type="text"
name="name"
#nameField="ngModel"
[(ngModel)]="characterForm.name">
<div
[hidden]="isNameValid || nameField.control.pristine">
Name must be between 3 and 50 characters
</div>
Conclusion
One of the things that makes it hard to discuss software architecture is thatthe solutions to big problems inevitably look like overkill when applied to small ones.If all we ever wanted to do was manage the names, ages, and skill sets of wizards,and if the entire application was only ever going to be maintained by its original author,we wouldn't want or need all of the machinery introduced above.However,these assumptions all too easily become self-fulfilling prophecies.If we don't architect our software to accommodate both expansion of functionand expansion of the development team,both will be so painful that we won't do them.
Our experience is thatcombining Angular 2 forms with Redux for state managementpays off sooner than you would think.Testing is a lot easier when all checks on changes to an application's statecan be written as before-and-after rules.(To see how much easier, compare the Redux testing examples to whatever you're doing now.)Just as importantly,a single reducer can often be used for many forms,and generic actions can be re-used as well.For example,most of what we wrote above to handle character skillscan be used to keep track of how many potions and scrolls they are carrying as well.
Of course,nothing is ever free.In large applications,it's important to design the structure of the Redux storeso that new instances of it can be created quickly,and so that the next programmer to inherit the application can easily figure outwhich parts of the state to update when.We are also still learning how best to co-design reusable parts of forms and reusable parts of stateso that reducers can be packaged and re-used as black boxes.Just as careful design of state makes the computer more efficient,careful co-design of components will,we hope,allow programmers to be more efficient.If you would like to share your experiences with this,we'd enjoy hearing from you.
原文: https://angular-2-training-book.rangle.io/handout/architect/functional_forms.html