Abstractions in general
Abstraction is a result of a process to generalize the context and arrange and hide the complexity of the internals. The whole computer science is based on this idea and if you are a front-end developer, there are multiple layers of abstractions already under the code you are writing. Abstraction is a very powerful concept and it speeds up development hugely if done correctly.
We see abstractions all around us and not just in software development. For example, automatic transmission in a car has two gears, R, D. These shifts abstract the necessary action to make the car either forward or backward so that the user can focus on driving. For example, if a user wants to make a car to go backward, the only two actions the user needs to think is putting the shift into R(everse) and pressing a gas pedal.
The same goes for programming where we continuously use abstraction. It begins at a very low level where the charge of the electrical current is converted into zeros and ones and goes all the way up to the ideas of the application you are developing. On a higher level, abstraction can be for example functions that standardize certain processes or classes which create structures for the data.
In React abstractions are done by using composition. Higher-level components combine standardized lower-level components to be part of the user interface together. For example, a button could be part of the feedback form which can be part of the contact page. Each of the levels hides relevant logic inside the component and exposes necessary parts outside.
For example, if we have a component that is responsible for an accordion, we can reuse the same component instead of re-writing it when we want an accordion to be part of the screen. We may need to have a different design or a bit different functionality but as long as the accordion in a screen acts as accordion, we can reuse the base functionality.
The key to success with the composition is to find the right abstraction layers for the project's components. Too many and too few layers of abstraction risk having redundant code and decelerating development speed. Too large abstraction layers mean that smaller common code components are repeated in each component. At the same time, too small abstractions repeat the usage of the components more than needed and having too many layers of code will slow the initial development.
The proper levels of abstraction are hard to estimate before the significant parts of the application are ready and incorrect abstraction levels are the usual a cause for the need of refactoring later on. Defining the responsibilities of the components before development helps to reduce the amount of needed refactoring because it forces to justify the decisions. I can also suggest to create a bit too many abstraction layers than too few because layers are easier and cheaper to combine.
In our accordion example, we first decided to expose the reveal and collapse functionality and color theme outside which means that accordion isn't any more responsible for that. This also means that we expect those two properties to differentiate a lot between the screen. Analyzing and determining the responsibilities for the components will help out see how components should be built the way that they are composable for your application. For me, this became obvious when in the latest project I have been involved.
Case: Forms in frontend of enterprise application
Around a year ago we started to build an application to speed up one of the company's processes. As usual with all these kinds of business applications, the software would handle user inputs to fill the necessary data and then turn it to a product. I'll use this project to showcase how the abstraction worked for us. I'm going to focus on how we build forms since they were the key for this software and they ended up being the best example of an abstraction that I have done.
Starting a project
Let's start with the starting point to get some understanding of the factors leading up to the decision we took. When the project began, the final state of the process was unknown like it usually is in agile development. Nonetheless, this allowed us to deal with a lot of uncertainty when defining abstracts, leading to much more careful analysis before the components were defined.
In the context of forms, the base requirements were that we could have multiple of forms with different inputs. For me, this meant that we should make the form components extendable to as many situations as we could think while keeping the core as standard as possible.
How we abstracted forms
Before we could start building the abstractions, we needed to understand the purpose of the forms. In our case, they are part of the process where a user can either create new data or alter the current data. While most of the data points are independent of each other, we still wanted to ensure that we can handle dependency either between the form fields or between a form field and a value from the server.
The purpose of the fields is also to limit the given set of values. Data types are the general cause to limit the input. For example, when requesting a number input, we should limit users' ability to give something else. We also should be able to limit the input to a certain list of values by either limiting the input or validating the input.
This process showed that we should have two abstractions; form and form field. Besides that, we noticed that we may have different types of fields if we want to limit the input in different ways.
Form
Based on the previous process description we decided that the form in our case will be responsible for handling the state of the form data and validations. It should be also possible to give initial values and trigger the submit. The form shouldn't care where initial values come from or what happens on submit which means that these two should be exposed.
const Form = ({ initialValues, onSubmit, children }) => {
return children({ ... })
}
Field
For the fields, we defined that we would need different types of limits for what the user can inputs. If there would be just a couple of different options it would make sense to include the logic inside the abstraction. For us, it was obvious from the beginning that we would have a lot of different types of data so we should expose the logic outside. And this wouldn't be only the logic but also UI part of each limit. For example, when we want user only to choose from the list, we should create a UI (ie. a drop-down) for that.
All field elements also had some common elements like a label on the top or the side of the input and possible error or information message under the input. These we decided to include inside the abstraction since we expected these to be part of the all form fields.
The result of these two decisions ended up creating two different abstractions. A field that is responsible for the data and surroundings of the input and an input type that is responsible to show the input field. Each of the different input types like TextInput would be their components which would all fill the same responsibility but a different way.
const Field = ({ name, label, inputComponent: Input, inputProps }) => {
const value = undefined /* Presents the value */
const onChange = undefined /* Changes the value */
return (
<React.Fragment>
{label}
<Input
name={name}
value={value}
onChange={onChange}
{...inputProps}
/>
</React.Fragment>
)
}
// Text input in here is an example
// The props would be the same for all inputTypes
const TextInput = ({ name, value, ...props}) => (...)
const App = () => (
<Form>
<Field
label='Test input'
name='TestElement'
inputComponent={TextInput}
/>
</Form>
)
Executing the abstraction
After we got the abstractions and requirements for those abstractions ready, it was clear that our setup is universal so someone else should have solved the problem already. Using a ready-made package would ease our job because we wouldn't have to build everything from scratch. After some exploration, we ended up using Formik inside our abstraction.
I would like to note that we are not exposing Formik to our application fully but only on Form and Field level. Formik is only filling the functionality of the abstraction, not creating it for us. This gives us an option to replace the package if we ever need something different in the future and we can also extend our abstraction beyond what Formik provides. The downside of this practice is that we need to write additional integration tests to ensure that Formik works along with our components as it should.
Creating input types
The last piece from the form setup was the input types. Since on the Field level we exposed the input, we would need to have a separate component to fill the responsibility.
It became very obvious while we had created some of these input types that besides data types (ie. text, number, date), the input type component depends on how we want to limit users' selection. For example text, input and group of radio items serve the same purpose but limit the selection very differently. We ended up having roughly 20 different input types in our application. The reason for so many components was that we wanted to abstract each input separately. For example text and number, input looks almost the same but they act differently. For the developer, it would be also easier to distinguish the inputs if they are different components.
This didn't make us repeat a lot of code since the input components were composed of smaller components. I have liked very much the way atomic design splits components because it describes the abstraction layers reasonably well and helps to keep components composable.
For inputs we created two abstraction layers:
- Atoms - single functionality component like the design of the input field, functionality of a tooltip popup.
- Molecules - composes atoms to build higher-level items like in our case input type component.
In our case, for example, the input component was reused between half of the input components because it was so generic. Probably the best example of having composable atoms in our case is Datepicker.
Datepicker example
In the beginning, we used the browser way to handle dates but since we wanted to have the same looking field in all browsers, we decided to do our own. After exploring the available packages and we decided to use fantastic @datepicker-react/hooks hooks and create our design on top of that.
Since we already had a lot of atoms developed, we only needed to create the calendar design which took something like 1.5 days to do from start till the end including tests. In my opinion, this demonstrates the power of the well-chosen abstraction layers which help to generalize the small component into composable atoms.
Conclusions
Generic abstract and composable components speed up development as each new feature also generates reusable components. Once we started developing the Datepicker, this became obvious to us. We've already had all the other components except the calendar itself.
Defining responsibilities for the abstracted components eases up selecting the exposed and hidden logic inside the component. It makes the conversation more constructive within the team as we end up talking about architecture rather than implementation. For example, we specified at the beginning that we expose the input component outside of our Field component. The strongest reasoning for this was that we may end up with a significant amount of different types of fields and we don't want to limit usage inside the field.
Structuring the abstraction layers with some rules helps to declare the responsibilities and connection between abstraction layers. We used atomic design as a base for these rules. It defines five abstraction layers and gives them high-level responsibilities. This helps in the beginning to establish components which have the same abstraction level.
Thanks for reading this. If you have had same experience or have any comments or questions, I would gladly hear them.
Top comments (5)
Thanks for the amazing article. I built a similar library to manage forms on my own which is more or less the same concept (Form, Field, etc) and works fine. The only issue I faced was related to image uploader with thumbnail because onSubmit it updates the state and it re-renders the fields again and images were flickering.
I will give formik a try, probably it will solve my problem.
If you want to prevent re-endering use refs. That's what React Hook Form does. When you call useForm it's actually returning _formControl.current which is ref object that contains all the stuff you normally destructure from useForm.
Thanks :)
I have been using and trying a punch of different form packages and for me Formik has worked the best so far.
Great article. It feels really nice to read. Thanks.
Big thanks! :)