A Semantic Strictly Typed React.js Forms Builder: Syntax
This is a series of articles on progressively improving the types of a highly dynamic React Component.
This is part 1/3. Part 2 is coming in a bit
In this part, I’ll propose and justify a reasonably practical, semantic Component that can dynamically render Forms with a special JSON syntax. Then, I’ll introduce a super basic type for it.
In part 2, I’ll significantly improve the type definition.
Forms are a neat topic because they’re universal in our apps. It’s how we capture user data. Normally we define forms by hooking together HTML input components, but if we break down the process of building a form, we can streamline it.
💩 CRUDdy Forms
They say that a lot of the apps we build are just fancy forms that do… CRUD. CRUD stands for Create, Read, Update, and Delete, and represents the basic operations that we use when working with a database. We’re really just building an interface to interact with a database.
If we create a high-level breakdown of an application, we’ll have a loop that looks like this:
We down an object from the database to the client (user), and the user fills out or edits a form and sends the same object back to the server with new values.
Usually the API is RESTful (REpresentational State Transfer) as resource structural consistency is one of the general principles of REST. Meaning to say that the structure of the object/resource doesn’t change throughout its journey. This allows us to ensure that the form can just modify the values of the object we GET from the server and the client can just
send (PUT) it back up as-is.
🧠 Theory of a Form System
So essentially, when we define a form we:
- Map a resource to a bunch of fields (like age would be a number field)
- Map those fields to a layout
- Generate a form: fields + layout + values = Form
- Collect inputs
- Send back the result back to the server
With this in mind, we can define a simple, consistent syntax to represent these mappings.
Consider a resource that looks like:
We can map this to a set of fields:
And map the fields to a layout:
Lastly, we feed these into something that will render it for us:
⚛ React is Pretty Powerful
With React it’s easy to dynamically render forms using the
Defining forms in this manner improves the developer experience (DX) through the sheer ease of the readability. Rather than reading and writing a bunch of JSX and hooking up values and inputs, we can abstract all of that away.
- Resulting syntax is super easy to scan through.
- All fields have a standard interface
- Layout is abstracted away from the fields (allowing multiple layouts)
- No hooking up fields to change handlers
- No code to render fields/layout
- Memoization/performance can be managed by
Sure, the JSX for a form that’s a single column of fields is not hard to read. But when you start to introduce more complex layouts, everything gets jumbled and it becomes harder to scan quickly.
In this case, when focusing on the fields, you don’t need to worry about the layout and vice-versa.
💻 Codifying a Design System
A design system is really 2 things:
- Rules for consistent components
- Protocols to keep them interoperable
By codifying patterns and establishing standards, design systems allow us to create consistency and build quickly. We’ll be able to achieve structured flexibility — flexibility with guardrails.
We can gain 3 particular coding benefits from this system:
- Consistent interfacing protocols (how existing and new components connect with one another) reinforced using types
- Lock down UI/UX rules like spacing with named space options like
'medium'rather than using numbers
- Pipeline: we can define a core library of components that you can reference using a keyword like
checkboxbut also support custom components that can eventually be added into the system after vetting
We can codify UI/UX rules into this Form System to maintain a consistent theme, locking down how fields should be laid out in a form.
Users subconsciously learn from the UX patterns set inside your app. One of the worst thing to do is to gaslight a user by introducing conflicting patterns in your app. It’s often hard to keep track of this design debt. Subtle inconsistencies can result in what users call a “clunky” app.
Rules can also be defined such as those in the example syntax prior:
- Spacing is consistent but overridable with named widths:
- Labels and headers are consistent
- Fields look consistent
We end up with quickly built forms that look and feel like they are part of the same application:
We can also ensure that the system and its library of fields support variants like
Fields and forms should both be able to be designated as
readonly. Here’s an example of turning an entire form into a disabled state:
A form is just a structured breakdown of a resource’s data, so a
readonly view can serve as a basic details page. This can be useful for more advanced features like in-line editing, through flipping the
readonly status of individual fields.
⚖ Balancing Rigidity and Flexibility
Systems can help us build quickly but overly strict design systems can hinder us by making it difficult to add new patterns and experiment.
And overly flexible systems would be chaotic and clunky. There needs to be the right balance so that even experiments can continue to look great.
Supporting custom components would help to create a good balance. Things should continue to look consistent because form labels, spacing, etc. will continue to look the same.
Suppose that we had a special
<LocationSearcher /> field we’re experimenting with, we can just set it as the
options object represents the props that would be passed along to the custom component. In fact, since any component is supported, we can nest Forms inside Forms, by using it as the field’s
We can further the idea of flexibility vs. rigidity by opening up the layout portion to support more interesting layouts while maintaining a good level of consistency.
📰 Multiple Columns:
For example, a way to define columns is by nesting layouts:
🔗 Combined Fields:
Notice in the last example there was something interesting.
With a system in place we can do things like support combining fields through the layout’s definition
Anonymous Display Fields:
In the Pizza Party form before, you may have noticed something else interesting, the dash between these fields:
So sometimes you may want to add things into the form that aren’t input fields, but you still want it to adhere to the spacing rules and look aligned. We can introduce anonymous fields to do this like so:
📱 Responsive Layouts
There’s nothing preventing one from defining multiple layouts for a set of fields, and that’s exactly how responsive layouts can be achieved. To define a mobile layout, one can simply define a layout that may just be a single column of single field rows.
With a rigid structure to define forms, it simplifies situations where you need to persist custom forms defined by a user, like what Google Forms does. You could even persist the fields and schema as JSON in a database.
🐛 Known Issues
width property should be part of the
layout because if you are defining multiple layouts, you’ll want to change the
width of the
field, this will be addressed in a future update because I already defined all the TypeScript types.
I won’t go into details about how to code the
<Form /> component (unless I get some requests). It’s a basic exercise in React.js on how to build a component that deeply maps from a dynamic schema into JSX.
The more interesting picture is the other half of the form, defining the types.
🟦⚛ TypeScript + React is Amazing
So as promised from the introduction, we can create a very basic type for this component:
And that’ll mostly work, but there’s a huge loss of fidelity here. Since we’ve basically encoded a JSX Form into this… object-based syntax, we’re hiding the JSX from TypeScript. TypeScript can’t look at and dissect things.
(give example where passing a value with inappropriate values to the field component)
With the basic type we lose out on defining rules based on how these parameters should relate to one another like:
valueshould be compatible with the
layoutshould refer back to the existing
I’m going to explore this topic in significant depth in the next article. We’ll be able to trade some level of type-safety for some other levels of type-safety. But as a first step, let’s just outline the rules between the different parts of the form that we’ll try to codify using TypeScript.
Defining the Relationships in <Form />:
The resource (value passed between the client and server) maps to the fields.
- The field names inside the fields object must match the keys inside the resource
The components map to the fields.
- Each field component supports some types like
string, these must work with the resource, for example, if
Datethen we should be using a
- The definition for
optionsinside fields holds the props for a field component, it must be compatible with that component. For example, you can’t give a
'counter'type field, a
Lastly, the fields map to the layout.
- We can check if the fields being referenced in the
layoutrefer back to the keys in the fields