How to Create React Forms with Functional Programming Patterns
Our primary goal is to build forms that are completely composable, meaning that we should be able to take two completely different forms, snap them together like LEGO bricks, and then drop them on a page without needing to sacrifice a goat or a kidney to the Form-Gods. To do this, we will split our forms into three different levels of abstraction.
Since abstraction is a fancy word that gets bandied about regularly in buzz-word bingo, so let’s be clear about what I’m talking about.
“The purpose of abstraction is not to be vague, but to create a new semantic level in which one can be absolutely precise.” — Dijkstra
The Meh (accurate, but vague)
In software engineering and programming language theory, the abstraction principle (or the principle of abstraction) is a basic dictum that aims to reduce duplication of information in a program (usually with emphasis on code duplication) whenever practical by making use of abstractions provided by the programming language or software libraries. — Wikipedia
The Ugly (…yes …but so not the point)
…in Object-oriented programming, abstraction is a process of hiding the implementation details from the user, only the functionality will be provided to the user. In other words, the user will have the information on what the object does instead of how it does it. — TutorialsPoint
Dijkstra nails the concept of abstraction so well that it basically memes itself in a way only Dijkstra can achieve, but to be more specific, let’s think in psychological terms for a moment.
The human brain is built to abstract information to provide context. When we first learn about a giraffe, our brain has to encode that information somewhere, and so it will likely toss it somewhere near the concept of animals. However, an animal is not an abstracted giraffe, nor is a giraffe an abstracted animal. A giraffe is an abstraction of the concept and mental image of an animal with long legs and necks and funny spots.
My Personal Take on Abstraction
An abstraction in computer programming is the encapsulation of functions and/or data structures that contextually captures a specific behavior or entity of a program to both minimize the duplication of code as well as provide clarity of purpose.
This does not stipulate what is a good or bad abstraction, but I think it at least kind of points in the general direction.
This is in stark contrast to the last definition (the ugly) which is the most common definition I’ve seen. It is not intrinsically wrong, but for my money it is too focused on a general byproduct — like saying the definition of eating is to provide poop for your toilet. If our primary goal is just hiding implementation details then all we are really doing is giving penguins hang gliders — sure, it looks cool, but it might serve no functional purpose because the new semantic level might be too tightly coupled with the surrounding code.
Form abstraction should occur in three places
- Form Inputs: transforming events into domain model objects.
- Form Control: controlling form state and submission events.
- Form Validation: any logic that determines if data is valid or not.
Form Inputs :: Event Transformation
The heart of this component is just three functions:
NameValidations :: returns a validation object that will handle all of our validation requirements for a Name objectvalidateEvent :: transforms an event into an object that will be handed off to the validation state management.updateState :: transforms an event into an object that will be handed off to the form state management.
What is Through?
Sometimes, a single event (e.g. a redux action) will kick off a series of independent functions with the same argument / payload. We could compose them together by wrapping the side-effects in functions that return the original payload (much like a trace function), however that’s not really the idea. What we want is something that acts like a cue ball on the break in a game of pool.
To create this, I leaned on a function available in Wolfram Alpha and built it with Ramda utilities:
R.curry((list, x) => R.map(R.applyTo(x), list));
The “list” parameter will be a list of functions, and the “x” parameter will be the argument to supply each function of the list. With this in mind, it should be easy to see that “handleChange” is actually just a pool cue for driving an event (the cue ball) through a validation transformation and a domain model transformation.
The most important thing in this component is that these inputs are completely stupid (i.e. they have no knowledge of where their data comes from and are incapable of submitting data). This is important because state management is not their job. They are the RNA of the form — slicing and gluing protein building-blocks to transform an event into an object that matches a piece of our domain model.
Form Control :: State Management / Submission
The heart of this component centers on three functions as well:
PandaValidations :: returns a validation object that will handle all of our validation requirements for a Panda objecthandleChange :: a through function that handles state managementhandleSubmit :: a conditional function that kicks off one of two dominos (onSuccess or onFailure)
Form Controls have no knowledge of the forms they contain — they are the DNA of the form with knowledge of the state to feed the forms their data and an interface for updating that data. For this reason, Form Controls will never have any inputs on them. This form control wraps a PandaForm which composes 3 different forms togehter to allow a user to create pandas: NameForm, FriendForm, and FoodForm.
While this form is more complex than the NameForm shown earlier (dynamic friends plus two other nested forms, at its core it is essentially no different. It has more complex data types than the Name object, so it uses a dictionary lookup to determine which transformation to execute, and if it had data that wasn’t encapsulated by nested objects it would still also contain it’s own input fields. But as it is, since all the data for a panda are actually defined by other nested models, a panda form is just the amalgamation of three previously defined forms with it’s own transformations. This means if we needed to nest a PandaForm inside a ZooForm, we could make a form control component for a zoo and compose them all together.
Broadly speaking, validations center around three behaviors:
- Rendering an error for a single field (onBlur)
- Removing an error for a single field (onChange)
- Validating all fields (onSubmit)
Most form libraries will update a validation state with every update to the form state, and then decide whether or not to render that error for the field if the field has been “touched”. However this property is actually quite pointless and can be ditched all together if we only update the validation state on a change event when the validation passes, and then always update the validation state on a blur event regardless of passing or failing. By using a touched property, validations become “live” after any previously touched fields are typed in again which is a great way to start triggering a user. Most users prefer to be notified about errors only once they have finished entering data, “Don’t tell me my email is wrong, I’m still typing it out!” The psychology of negative reinforcement is beyond the scope of this blog but if you want to avoid triggering users who are sensitive to errors, only make validations live after a failed submission attempt where the punishment of failing to submit is more salient than the frustration of negative reinforcement.
Library or no library?
You are welcome to use any validation library you like as long as you are able to inject your validation solution into each form and form control, and ideally, abstract them at the same levels of the domain model. If objects with chain-able properties make you randy then check out Yup.js. With Yup, you will want to make some helpers to customize when you want the validations to occur and decide how you want to store that information.
Personally though, for my taste, I just want to define functions because the second I have to start digging through documentation to try and customize the behavior of something as simple as validation logic — it’s like sitting on hold with “It’s a Small World” playing in my ear the entire time. So the provided code is using a library called “@de-formed/react-validations ” which is my own library — shameless plug alert — but will provide a data structure to store functions and provide an interface to call them later. This will make it easy to customize and compose our validations and make sure they are production ready.
Example “de-formed” Validation Schema
How does Functional Programming help with production code?
Instead of just promising double-rainbows and unicorns, let’s walk through the life-cycle of a single line of code in the real world.
Imperative Ian sits down to write some validation logic for a required string property.
(str) => str.length > 0;
Admiring his masterpiece, Ian stops and reflects on his code and feels great — it’s short, sweet and fits on one line. He puts up his PR and his teammate, Bob, rubber stamps him because Bob also thinks this is terrific. Then, Ian working on the next ticket suddenly hits a runtime error that “length does not exist on str”. Ian stares at the code with a slow blink and his head cocked like a puppy hearing their first human fart — “how could this fail!?” Upon closer examination, Ian realizes that due to some asynchronous process or because Mercury is in retrograde again that “str” might be undefined, so he updates it.
Iteration 2: fix “length does not exist on undefined” runtime bug
(str) => str ? str.length > 0 : false;
Ian, feeling sheepish about the bug but confident in his solution, puts up another PR and Bob rubber stamps it again (thanks Bob). But then, Ian sees a bug report appear on the board: people are sending empty strings to the API and evading his validation by adding empty spaces (thank you User), so Ian updates it again.
Iteration 3: fix users entering spaces
(str) => str ? str.trim().length > 0 : false;
Ian, significantly more deflated and never wanting to see or read this line of code again, gets called into a meeting the following day with the data team who announce that due to some butterfly that pissed on an anthill in Azerbaijan, the property that he had written the validation for was going to need to become an object with multiple new properties on it. Ian cries while on mute, and updates his logic once again.
Iteration 4: extract value from object
(obj) => obj && obj.str ? obj.str.trim().length > 0 : false;
Although he could have tried to refactor it, Ian just wants to close the ticket faster than a horse escaping a glue factory so he puts up his PR. This time, Bob — noticing the code has more symbols than words now — comments back, highlighting the line with “ummmmm, what happened here?” to which Ian responds with, “I blame Mercury” and the PR enters the code base like a dormant zombie waiting to eat someone’s brain later in the movie.
While this is a pretty finite straw-man example that has definitely neeeever happened to me personally, this kind of cycle is one of the many ways that developers inadvertently start to mutate their production logic into brain eating zombies. They started out with a piece of slick code, but in the day-to-day as the edge cases and feature updates started to emerge and pile on, the logic grew into something that became as leaky as a poop-Popsicle in Phoenix.
But I digress…
A functional approach makes things easy to modify and easy to read so a future maintainer doesn’t get attacked by a dormant zombie when they open the file:
Iteration 2: fix “length does not exist on undefined” runtime bug
Iteration 3: fix users entering spaces
Iteration 4: extract value from object
Every time we need to update the logic, we just toss another transformation function in the pipeline and add a new automated test case if needed.
Back to Validations
The truth is that you can piece together a pretty robust validation library with just an object and a couple reducers so long as you have control over when you want to call them (a miniature validation system can be found in the API code). What is important for our functional approach is that validations are just functions that take a value and return true or false, and a valid validation state is the reduction of all validation functions down to a single boolean.
Whether you are building validations from scratch or using a validation library, the only requirement is that each form be capable of handling its own events.
Some Final Considerations
Note that there are no form tags anywhere, and that’s totally fine. There is no requirement for an explicit form tag for accessibility or functionality but if you need one for any reason the form control would be the place to put it since you should never have the need to compose form controls.
Each form will have a useEffect hook to handle side effects. This is to be expected because our form controls are ignorant of their children and our forms are ignorant of their parents. To overcome this, we pass the side-effect from the parent down to each child with useEffect so that validation information will asynchronously bubble down from the form control to all nested forms. I like to handle this behavior by providing a boolean state that tracks if a submit event has failed, and listen to it in all forms. If that boolean changes to true, we tell each form to run their validation functions against the data passed down and voila, each form populates error messages for your user. We can also leverage this side effect to pass validation errors from the API and override front-end validations with API errors.
React Application: here is a link to a repo with the complete code shown above as well as other oddities. Browse around, poke at stuff, break it, fix it, and make it better!
Node Server: here is a link to an optional repo with a little express API that the React Application will try to call on port 5000 when submitting. If you are only interested in playing with the React side of things this API is not necessary. The API also offers a look into how you can leverage a couple reducers to create your own validation logic.