React Design Patterns: Generating User-configured UI Using The Visitor Pattern
Andrew Hansen
Posted on August 16, 2021
I had a problem in my React app: I needed to render a form with multiple inputs of multiple types: date fields, number fields, dropdowns: the usual suspects.
But here's the kicker: similar to form builders like SureveyMonkey or Typeform, users need to be able to design these forms themselves and configure them to include whatever fields they need.
How do I go about this? Users won't be writing React themselves so I need a data model that describes their form's configuration. While data structures and algorithms are not typically my strong-suit, what I landed on is what I came to realize is the Visitor Pattern but implemented with React components.
What is the visitor pattern?
The Wikipedia page for the visitor pattern describes the visitor pattern as "a way of separating an algorithm from an object structure on which it operates". Another way to put this is it changes how an object or code works without needing to modify the object itself.
These sorts of computer science topics go over my head without seeing actual use cases for the concept. So let's briefly explore the visitor pattern using a real-world use case.
Babel is a great practical example of the visitor pattern in action. Babel operates on Abstract Syntax Trees (ASTs) and transforms your code by visiting various nodes (eg, blocks of text) in your source code.
Here is a minimal hello world example of how Babel uses the visitor pattern to transform your code:
// source.js
const hello = "world"
const goodbye = "mars"
// babel-transform.js
export default function () {
return {
visitor: {
Identifier(path) {
path.node.name = path.node.name.split('').reverse().join('')
}
}
}
}
// output.js
const olleh = "world"
const eybdoog = "mars"
You can play with this example yourself here.
By implementing the Visitor Pattern, Babel visits each Identifier
token within source.js
. In the above example, the Identifier
tokens are the variable names hello
and goodbye
.
When Babel finds an Identifier
, it hands things over to our transformation code and lets us decide how we want to transform the token. Here, we reverse the variable string and assign the result as the new name for the variable. But we could modify the code however we want.
This is powerful because Babel does all the heavy lifting to parse the source code, figure out what type of token is where, etc. Babel just checks in with us whenever it finds a token type we care about (eg, Identifier
) and asks what we want to do about it. We don't have to know how Babel works and Babel doesn't care what we do in our visitor function.
The Visitor Pattern In React
Now we know what the visitor pattern looks like as a general-purpose algorithm, how do we leverage it in React to implement configurable UIs?
Well, in this React app I'm building, I mentioned I would need a data model that describes a user's configured custom form. Let's call this the form's schema
.
Each field in this schema has several attributes like:
- Field type. eg, dropdown, date, number, etc
- Label. What data the field represents. eg, First name, Birthdate, etc.
- Required. Whether or not the field is mandatory for the form.
The schema could also include other customization options but let's start with these.
We also need to be able to enforce the order in which each field shows up. To do that, we can put each field into an array.
Putting that all together, here's an example schema we could use for a form with three fields:
const schema = [
{
label: "Name",
required: true,
fieldType: "Text",
},
{
label: "Birthdate",
required: true,
fieldType: "Date",
},
{
label: "Number of Pets",
required: false,
fieldType: "Number",
},
]
The Simple But Limited Approach
How might we go about rendering this in React? A straight-forward solution might look something like this:
function Form({ schema }) {
return schema.map((field) => {
switch (field.fieldType) {
case "Text":
return <input type="text" />
case "Date":
return <input type="date" />
case "Number":
return <input type="number" />
default:
return null
}
})
}
This is already looking a bit like the visitor pattern like we saw with Babel. And, this could probably scale decently for a lot of basic forms!
However, this approach is missing the key aspect of the visitor pattern: it doesn't allow customization without modifying the implementation.
For example, maybe we want to be able to re-use this schema for other use cases like a profile view, we would have to extend our Form
component to capture both use-cases.
The Customizable Visitor Pattern Approach
Let's formalize our usage of the visitor pattern to enable full customization of our schema
rendering without needing to modify the Form
implementation:
const defaultComponents = {
Text: () => <input type="text" />,
Date: () => <input type="date" />,
Number: () => <input type="number" />
}
function ViewGenerator({ schema, components }) {
const mergedComponents = {
...defaultComponents,
...components,
}
return schema.map((field) => {
return mergedComponents[field.fieldType](field);
});
}
This new ViewGenerator
component achieves the same thing Form
was doing before: it takes in a schema
and renders input
elements based on fieldType
. However, we've extracted each component type out of the switch statement and into a components
map.
This change means we can still leverage the default behavior of ViewGenerator
to render a form (which would use defaultComponents
). But, if we wanted to change how schema
is rendered we don't have to modify ViewGenerator
at all!
Instead, we can create a new components
map that defines our new behavior. Here's how that might look:
const data = {
name: "John",
birthdate: "1992-02-01",
numPets: 2
}
const profileViewComponents = {
Text: ({ label, name }) => (
<div>
<p>{label}</p>
<p>{data[name]}</p>
</div>
),
Date: ({ label, name }) => (
<div>
<p>{label}</p>
<p>{data[name]}</p>
</div>
),
Number: ({ label, name }) => (
<div>
<p>{label}</p>
<p>{data[name]}</p>
</div>
)
}
function ProfileView({ schema }) {
return (
<ViewGenerator
schema={schema}
components={profileViewComponents}
/>
)
}
ViewGenerator
maps over the schema and blindly calls each of the functions in profileViewComponents
as it comes across them in the schema
.
ViewGenerator
doesn't care what we do in that function, and our functions don't have to care about how ViewGenerator
is parsing the schema. The components
prop is a powerful concept that leverages the visitor pattern to lets us customize how the schema is interpreted without needing to think about how the schema is parsed.
Extending The Framework
Our app has a new requirement for these user-configured forms: users want to be able to group input fields into sections and collapse content to hide them.
Now that we have a framework for implementing basic user-configured forms, how would we extend this framework to enable these new capabilities while still keeping our schema and view decoupled?
To start, we could add a Section
component to our components
map:
const components = {
Section: ({ label }) => (
<details>
<summary>{label}</summary>
{/* grouped fields go here? */}
</details>
)
}
But we don't have a good way of identifying which fields are related to our Section
. One solution might be to add a sectionId
to each field, then map over them to collect into our Section
. But that requires parsing our schema which is supposed to be the ViewGenerator
's job!
Another option would be to extend the ViewGenerator
framework to include a concept of child elements; similar to the children
prop in React. Here's what that schema might look like:
const schema = [
{
label: "Personal Details",
fieldType: "Section",
children: [
{
label: "Name",
fieldType: "Text",
},
{
label: "Birthdate",
fieldType: "Date",
},
],
},
{
label: "Favorites",
fieldType: "Section",
children: [
{
label: "Favorite Movie",
fieldType: "Text",
},
],
},
]
Our schema is starting to look like a React tree! If we were to write out the jsx for a form version of this schema it would look like this:
function Form() {
return (
<>
<details>
<summary>Personal Details</summary>
<label>
Name
<input type="text" />
</label>
<label>
Birthdate
<input type="date" />
</label>
</details>
<details>
<summary>Favorites</summary>
<label>
Favorite Movies
<input type="text" />
</label>
</details>
</>
)
}
Now let's update the ViewGenerator
framework to support this new children
concept and enable us to generate the jsx above:
function ViewGenerator({ schema, components }) {
const mergedComponents = {
...defaultComponents,
...components,
}
return schema.map((field) => {
const children = field.children ? (
<ViewGenerator
schema={field.children}
components={mergedComponents}
/>
) : null
return mergedComponents[field.fieldType]({ ...field, children });
})
}
Notice how children
is just another instance of ViewGenerator
with the schema prop set as the parent schema's children
property. If we wanted we could nest children
props as deep as we want just like normal jsx. Recursion! It's turtlesViewGenerator
all the way down.
children
is now a React node that is passed to our components
function map and use like so:
const components = {
Section: ({ label, children }) => (
<details>
<summary>{label}</summary>
{children}
</details>
)
}
Section
is returning the pre-rendered children
and it doesn't have to care how children
are rendered because the ViewGenerator
component is handling that.
You can play with the final solution on codesandbox:
Conclusion
Nothing is new in software. New ideas are just old ideas with a hat on. As we see in the example above, it doesn't take much code to implement the visitor pattern in React. But as a concept, it unlocks powerful patterns for rendering configuration-driven UIs.
While this article covered building a configurable "form generator" component, this pattern could be applicable for many situations where you need configuration (aka, schema) driven UI.
I would love to see what use-cases you come up with for your own ViewGenerator
framework. Hit me up on twitter! I'd love to see what you build.
Additional Resources
- react-jsonschema-form is a React library that generates forms based on a json-schema and uses concepts very similar to the ones introduced here
- If you want to learn more about Babel plugins, the Babel plugin handbook by Jamie Kyle is a great resource for walking through a practical application of the visitor pattern.
- This Tutorial on the visitor pattern in JavaScript shows a brief example of the visitor pattern with just vanilla JavaScript.
Posted on August 16, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
August 16, 2021