De reis van Hoppinger met React en TypeScript
In dit artikel bespreken we de reis van Hoppinger door het uitdagende terrein van moderne client-side webapplicaties met React en moderne JavaScript-varianten zoals TypeScript. Door ons te verbinden aan de strengste software-engineeringdisciplines van statische typering, referentiële transparantie en functioneel programmeren, zien we een duidelijke verbetering in de kwaliteit en betrouwbaarheid van onze producten. Ook de efficiëntie van onze ontwikkelaars stijgt, vooral dankzij de flinke afname van bugs en fouten.
De voordelen van React en TypeScript
Ongeveer een jaar geleden maakten we bij Hoppinger de overstap naar React en TypeScript. Het doel was om te stoppen met wirwar aan jQuery-code en single page applications (SPA's) te omarmen.
De grote verbetering van React ten opzichte van jQuery is simpelweg het ontbreken van veranderlijke gedeelde state. Wanneer componenten alleen via callbacks kunnen communiceren, worden ze effectief referentieel transparant: elk component gedraagt zich altijd hetzelfde zolang het dezelfde begineigenschappen krijgt.
Neem bijvoorbeeld de volgende code:
let x1 = f(1)
...
let x2 = f(2)
...
let x3 = f(1)
...
Wat verwachten we van zo'n eenvoudig stukje code? De eerste, eenvoudige interpretatie zou verwachten dat x1 en x3 gelijk zijn: ze komen immers allebei van f(1). Wanneer dit niet gebeurt, betekent dit dat we veranderlijke state delen tussen berekeningen. Dit leidt vaak tot onvoorspelbare code die alleen in specifieke (en vaak slecht begrepen) contexten bruikbaar is.
React heeft dit probleem niet. Deze fundamentele betrouwbaarheid maakt React-componenten eenvoudiger te hergebruiken en met elkaar te combineren.
De komst van TypeScript
Naarmate we meer code schreven in React, werden we geconfronteerd met het probleem van het correct samenstellen van componenten met complexe interfaces. De vraag "welke eigenschappen verwacht dit component?" wordt zo vaak gesteld dat je je afvraagt of een betere ervaring mogelijk is.
<PersonForm
value={this.state.person}
setValue={p => this.setState({...this.state, person:p})}
editable={this.props.editable}
/>
Code zoals hierboven kan uitgroeien tot tientallen eigenschappen, en het wordt gemakkelijk om te vergeten dat de eigenschap editable eigenlijk edit heet, of dat PersonForm ook een eigenschap title verwacht. Door alleen naar de code hierboven te kijken, is het onmogelijk te zeggen of alle benodigde eigenschappen correct zijn ingesteld, ook al ziet het merendeel van de code er redelijk uit.
Kort na onze duik in React beseften we dat TypeScript het antwoord was op dit probleem: door statische typering en een alerte compiler kunnen we contextuele hulp krijgen wanneer we verkeerde parameters doorgeven aan functies en componenten. Als de eigenschap title ontbreekt, waarschuwt de compiler ons.
Het hoogtepunt
Op dit punt hadden we een tijdelijke staat van tevredenheid bereikt: we konden mooie herbruikbare componenten bouwen, en ze gebruiken en combineren ging moeiteloos dankzij de helpende hand van TypeScript.
De pijn van React en TypeScript
Natuurlijk was honeymoon fase al snel voorbij. We realiseerden ons dat de toolchain die we hadden opgezet nieuwe problemen introduceerde, maar dan op een hoger abstractieniveau. Een van de meest duidelijke nieuwe problemen was de grote hoeveelheid formaliteiten rond de definitie en aanroep van een React-component. Veel componenten met slechts wat basale lokale state zien er zo uit:
type Props = { ... }
type State = { ... }
class Comp extends React.Component<Props, State> {
constructor(p:Props, context:any) {
super(p, context)
this.state = { ... }
}
componentWillMount() {
...
}
componentWillUnmount() {
...
}
componentWillReceiveProps(p:Props) {
...
}
render() {
...
}
}
Merk op dat we nog niet eens de kans hebben gehad om te specificeren wat het component eigenlijk doet: dit is allemaal omringende ruis.
Bovendien wordt het snel duidelijk dat zonder serieuze complexiteit, het bouwen van generieke abstracte bibliotheken van componenten een berg aan verbindingscode vereist. Dispatch-containers duiken overal op bij het bouwen van wizards of een soort state machine, en zien er ongeveer zo uit:
type Props = { ... }
type State = { step:"A"|"B"|"C"|..., ... }
class Comp extends React.Component<Props, State> {
constructor(p:Props, context:any) {
super(p, context)
this.state = { step:"A", ... }
}
componentWillMount() {
...
}
componentWillUnmount() {
...
}
componentWillReceiveProps(p:Props) {
...
}
render() {
return step == "A" ?
<AComponent data={this.state...} callback={x => this.setState(...)} />
: step == "B" ?
<BComponent data={this.state...} callback={y => this.setState(...)} />
: ...
}
}
Het maken van een formulier of een wizard, wat in principe heel eenvoudig is, wordt onhandig en omslachtig. Hoewel dit de grote kracht van deze combinatie van React en TypeScript niet teniet doet (het resultaat is vaak goed genoeg!), vroegen we ons af of het mogelijk zou zijn om betere bibliotheken en abstracties te definiëren.
Monads als oplossing
Gelukkig is dit soort problemen al eerder tegengekomen en opgelost in andere gebieden binnen de informatica. De functionele programmeer-gemeenschap stuitte tientallen jaren geleden op het concept "monads" in hun zoektocht naar het vertalen van elegante wiskundige en logische constructies naar programmeertalen. Monads zijn een zeer abstracte constructie die verrassend flexibel blijkt te zijn en in staat is om problemen op te lossen met betrekking tot:
- concurrentiebeheer (async/await in ES6, TypeScript en C#);
- querybeheer (flatMap in Immutablejs, Stream in Java, LINQ in C#);
- collectiebeheer (generators in Python en JavaScript, yield return in C#);
- en veel meer...
De impact van monads is zo groot geweest dat sommige talen voorop lopen in innovatie (Haskell, F# en Scala onder andere) door een uitbreiding van hun syntaxis te krijgen om het gebruik van generieke monads visueel aangenamer te maken (de do-notatie in Haskell en computation expressions in F# zijn twee interessante voorbeelden).
Van monads naar React
Soms raken we verloren in de meer operationele aspecten van een technologie, waardoor we het grotere geheel uit het oog verliezen of details abstraheren die eigenlijk relevant waren (en dus niet geabstraheerd moeten worden!).
Neem een HTML-besturingselement, zoals <input type="text" … />. Wat zijn de inputs en outputs? Een eenvoudig antwoord zou aangeven dat het input-element heeft:
- de door de gebruiker geschreven tekst als input;
- het invoervak in de pagina als output.
Deze gebruikersgerichte benadering is zeker belangrijk bij het bouwen van interactieve systemen, maar helpt niet tijdens de engineeringfase. Tijdens deze fase moeten we overschakelen naar een nieuw perspectief. We bouwen immers een netwerk van onderling verbonden elementen op een pagina.
Dit betekent dat, met betrekking tot dit netwerk van elementen, de <input type="text" … /> nu geherformuleerd zou moeten worden als hebbende:
- de initiële tekst als input;
- tekst als output (wanneer de gebruiker iets typt).
Merk op hoe de input en output zijn omgedraaid ten opzichte van de eenvoudige definitie, en hoe we nu alle details negeren die niet relevant zijn voor de compositie van elementen.
Het idee dat componenten geclassificeerd moeten worden volgens hun output (met betrekking tot ons netwerk van componenten) is verder onderzoek waard, aangezien het suggereert dat componenten gekoppeld kunnen worden wanneer een van hen waarden produceert die het andere component kan verwerken!
We kunnen deze gedachtegang voortzetten en een algemene definitie geven van onze monadic React-componenten, waarbij we vastleggen dat een component die een output van type A produceert, een React-renderbaar element (JSX.Element) is dat een voortzetting (cont) bevat die wordt aangeroepen wanneer het JSX.Element in staat is om nog een output te leveren:
type C = (cont:(_:A)=>void) => JSX.Element
Door nuttige monadic operaties toe te voegen (de bekende unit en bind/resolve en then/...) ontstaat een nieuwe bibliotheek: monadic React.
We kunnen nu gemakkelijk verschillende componenten aan elkaar koppelen. Stel dat we een component select_number:C hadden, dan zouden we alleen de even getallen kunnen afdrukken door te zeggen:
select_number
.filter(n => n % 2 == 0)
.map(n => `The last even number you typed is ${n}`)
.then(s => string("view")(s))
Het opschalen van deze aanpak naar formulieren, wizards, menu's en veel meer werkt tot nu toe goed. Hier is bijvoorbeeld de daadwerkelijke code van een formulier:
let course_form_sample : C =
simple_form_with_save_button("edit", c => `course_${c.Id}`,
[
{ kind:"string", field_name:"Name",
in:c => c.Name || "", out:c => (n:string) => ({...c, Name:n}),
get_errors:c=>c.Name.length < 3 ? ["The name cannot be shorter than three characters."] : [] },
{ kind:"number", field_name:"Points",
in:c => c.Points || 0, out:c => (p:number) => ({...c, Points:p}),
get_errors:c=>c.Points < 1 ? ["The course must be worth at least one point."] : [] },
{ kind:"date", field_name:"Begin",
in:c => c.StartDate, out:c => (p:Moment.Moment) => ({...c, StartDate:p})
get_errors:c=>[] },
],
download_course(1), upload_course)
De definitie van het formulier is in wezen declaratief geworden en is nu zeer moeilijk verkeerd te krijgen.
Bovendien, omdat het type van het object nu C is, kan het formulier zelf weer worden samengesteld, bijvoorbeeld om een groter formulier of een grotere pagina met meerdere kleinere componenten te bouwen die in harmonie werken. Zoals ze zeggen, het is schildpadden helemaal naar beneden.
De rest van de React-wereld?
Monadic React ondersteunt natuurlijk de integratie van zijn structuren binnen bestaande React-applicaties. Dit stelt ontwikkelaars in staat om het op zeer kleine schaal te introduceren, in de context van gemakkelijk vervangbare perifere functionaliteit, en het meer en meer te gebruiken indien gewenst en/of handig.
Tegelijkertijd kunnen bestaande React-componenten worden verpakt in een dunne adapter om te worden gebruikt binnen een monadic React-applicatie. Dit maakt het mogelijk om bestaande React-code te hergebruiken zonder deze te hoeven refactoren. Bovendien vereist het verpakken niet meer dan het doorgeven van een voortzetting (callback) die de rest van monadic React op de hoogte brengt van het feit dat het externe component gegevens heeft geproduceerd die verwerkt moeten worden.
De voordelen
De belangrijkste voordelen die we hebben opgemerkt zijn:
- we schrijven minder code: dit vermindert het aanvalsoppervlak waar potentiële bugs zich kunnen verstoppen;
- we schrijven minder saaie code: dit stelt ons in staat om ons te concentreren op functionaliteit, niet op het verbinden van allerlei technische onderdelen;
- we kunnen sjablonen definiëren: dit stelt ons in staat om een DSL per applicatie te maken;
- we hebben nog steeds code en gewone functies overal: we kunnen veel reguliere code schrijven tussen componenten;
- we kunnen gemakkelijk componenten op een typeveilige manier samenstellen en opnieuw samenstellen: dit stelt ons in staat om snel te experimenteren en SPA-applicaties te refactoren.
Nuttige links
Alle bronnen worden natuurlijk gedeeld onder een open source licentie (MIT). Het project heeft ook een npm-pakket.
Om de bibliotheek in actie te zien, kun je naar:
Conclusie
De wereld van webontwikkeling verandert snel. Innovatie gebeurt in een razendsnel tempo, en grote bedrijven leren gebruikers elke dag over de geweldige mogelijkheden die websites en webapplicaties bieden. Dit maakt het leven spannend, maar ook niet gemakkelijk voor webontwikkelaars: de technologie om deze verandering te ondersteunen neemt snel toe in complexiteit, aangezien de applicaties die klanten als norm beschouwen steeds interactiever en rijker worden.
Om voorop te blijven lopen, moet je dynamisch denken en de toepassing van betrouwbaardere engineeringpraktijken omarmen in plaats van ertegen te vechten.
Monads, en in het algemeen hogere principes uit de informatica bieden de gereedschapskist. Teams die durven te duiken in deze materie zullen gedijen in deze prachtig evoluerende wereld.