Towards Explicit State
Joshua HawxwellI recently, at work, made a postcode look-up component that worked in the manner you’d expect: enter a postcode, click “find address”, select the correct address from a list, finally show the parts of the address in textfields. This was written as a component in Angular 1.5, so the controller for it looked something like:
import AddressLookupService from '../../services/address-lookup.service';
import { IAddress } from '../../interfaces/address';
export default class {
static $inject = ['addressLookupService'];
// internal state
postcode: string;
choices: { id: string; text: string; }[];
selectedId: string;
address: IAddress;
// callback bound to the parent
onChange: (obj: { address: IAddress }) => void;
constructor(private addressLookupService: AddressLookupService) {}
// called when user clicks "enter address manually" button
manualEntry() {
this.address = {
line1: '',
line2: '',
townCity: '',
postcode: '',
}
}
// called when user clicks "find address" button
findAddress() {
this.addressLookupService.find(this.postcode)
.then(response => {
// clear previous choice
this.selectedId = null;
// setting address to null hides the textfields
this.address = null;
// setting choices to non-null shows the list of options
this.choices = response.items.map(x => ({id: x.id, text: x.name}));
});
}
// called when list item is selected
selectAddress() {
this.addressLookupService.retrieve(this.selectedId)
.then(response => {
// stop showing choices
this.choices = null;
// show the chosen address
this.address = {
line1: response.line1,
line2: response.line2,
townCity: response.townCity,
postcode: response.postcode,
}
this.addressChanged();
});
}
// called when address selected and when textfields change
addressChanged() {
this.onChange({ address: this.address });
}
}
Within this there are a few subtle connections between the template and whether variables are null/non-null. To make them clearer we can break up how this controller stores data in to distinct states.
Start
, the initial state of displaying the postcode textfield and a button to “find address”PostcodeEntered
, after clicking “find address” we are shown the same as Start but also a list of addresses to choose fromAddressSelected
, after selecting an address from the list we are shown the same as Start but also four textfields with the components of the address, to potentially edit further
These states can easily be modelled in Typescript through tagged unions.
interface Start {
state: 'Start';
}
interface PostcodeEntered {
state: 'PostcodeEntered';
choices: { id: string; text: string; }[];
selectedId: string;
}
interface AddressSelected {
state: 'AddressSelected';
address: IAddress;
}
type State = Start | PostcodeEntered | AddressSelected;
Now it is clear that if an address is being displayed we should not show a list
of choices, because we don’t have a list to show! The controller can now be
refactored to use this State
type.
export default class {
static $inject = ['addressLookupService'];
// since postcode is always shown it is declared in the controller, not State
postcode: string;
state: State;
onChange: (obj: { address: IAddress }) => void;
constructor(private addressLookupService: AddressLookupService) {}
manualEntry() {
this.state = {
state: 'AddressSelected',
address: {
line1: '',
line2: '',
townCity: '',
postcode: '',
},
};
}
findAddress() {
this.addressLookupService.find(this.postcode)
.then(response => {
this.state = {
state: 'PostcodeEntered',
choices: response.items.map(x => ({id: x.id, text: x.name})),
selectedId: null,
};
})
}
selectAddress() {
if (this.state.state === 'PostcodeEntered') {
this.addressLookupService.retrieve(this.state.selectedId)
.then(response => {
this.state = {
state: 'AddressSelected',
address: {
line1: response.line1,
line2: response.line2,
townCity: response.townCity,
postcode: response.postcode,
},
};
this.addressChanged();
});
}
}
addressChanged() {
if (this.state.state === 'AddressSelected') {
this.onChange({ address: this.state.address });
}
}
}
This maybe doesn’t look much clearer than before – and of course there are
those two methods that rely on being in a certain state – but the relationship
between state
and the template is much clearer since we no longer have to
change what is displayed depending on if certain variables are null or not…
hoping that in the future everyone remembers to set the correct variable to null
in the correct place.
<div ng-if="$ctrl.choices !== null">...</div>
<div ng-if="$ctrl.address !== null">...</div>
Instead the template can make use of explicit state names and know which values will be defined.
<div ng-if="$ctrl.state.state === 'PostcodeEntered'">...</div>
<div ng-if="$ctrl.state.state === 'AddressSelected'">...</div>
When you don’t have the reassurance you get when writing TSX or Elm, having obvious looking templates that are not dependent on certain variables makes changing things in the future much easier.