In this post, we will learn how HubSpot engineers set out to discover a better way to create signup flows for smoother customer experience to allow for more flexibility, easier maintenance, and the ability to easily engineer within our code base.
A signup flow seems like a very simple thing. You fill in your first name, last name, email, and boom, you’ve got an account. However, there is a lot of work going on under the hood. In HubSpot’s signup, we have 22 different flows each targeted to a different persona — a flow is defined as a collection of steps that ask the user for information before we can create a HubSpot account for them. We have different signup flows for our CRM, our Marketing Hub, HubSpot Academy, etc. It’s a very high-traffic app, getting one million views and 200,000 new accounts created each month. Being at the early stage of a new customer journey brings with it a lot of pressure, so it's imperative that we make sure our app is reliable and that it entices a user to sign up to HubSpot.
If it ain’t broke, don’t fix it?
The code and the flow design that we had was working. There was nothing wrong with how it worked or how it looked. If it ain’t broke, don’t fix it, right? That’s not how we looked at it. From a usability perspective, we wanted to prioritize user delight. Our old flow was described as “fine”, “pleasant enough”, and “simple”. Overall, a very “mediocre” experience. We wanted to redesign the signup flow so that we could easily personalize the flow to the user. That means adding targeting messaging to steps and changing what questions we ask.
When we looked at the code we had, we saw a lot of tangled logic. There were a lot of HTTP calls nested within form inputs. Each step of signup was intertwined with other steps. The step order was strict and it was extremely difficult to change. Each step had multiple inputs that were tightly coupled and could not easily be separated.
Overall, there was a general lack of consistency across the code base. Each section of code for each step was written in a different style. In our first step of signup (see screenshot below) we ask the user their first name, last name, and email. All of the logic for that step, including validation and enabling/disabling the next button, was all nested deep within the form. With this structure, it was difficult to get a big picture of what a section of code does without going through many files and going down a bit of a rabbit hole.
Within this codebase, we found it harder to implement our bigger swings. This resulted in trivial changes like inserting a question between two steps in one of our flows, becoming a complex challenge that would take several weeks to implement and was extremely error prone.
As a result of that tangling of logic and lack of consistency, it made making any sort of changes scary for the engineer and quite risky. Nobody can know everything, especially when we have so many signup flows. Making a change in one file could make a problem for a completely different flow.
This motivated us to completely rebuild signup and start fresh, rather than trying to detangle our current code. Our goal was to allow for more flexibility, easier maintenance, and the ability to easily engineer our bigger swings within our code base. However, we kept our old code base in maintenance mode so that we still had a stable code base running while we were creating an entirely new one.
Introducing Lego Blocks
We have affectionately nicknamed our code base, lego blocks. With LEGO®, you have all of these separate pieces and you can put them together in so many different ways. You can make a rocket, you can make a tree, you can make whatever your imagination comes up with! That’s how we wanted our signup funnel to look. We wanted to have separate pieces that we can put together to create a flow that matches our user’s needs to understand HubSpot’s value for them from the start.
The main idea of the lego blocks architecture is to decouple the User Interface (UI) from HTTP calls, form submissions, tracking calls,and more, to allow for a dynamic, easily interchangeable, and flexible signup flow structure. Each step is now a self-contained, independent entity. This makes it simple to completely change the order of the questions asked in signup and even add or remove steps on the fly, depending on the user’s actions. For example, we can dynamically build a signup experience based on query parameters, experiment treatment or even the user’s input.
A signup flow is a collection of steps. We have a FlowConfig that allows the engineer to customize the signup on the flow level. Customizations include a list of steps to be asked to the user, whether or not to skip certain steps, enabling google signup, etc. Once the flow is customized, we can focus on the steps themselves.
Within signup, we have the notion of a Step and a Data point. In our previous design, we had rigidly defined steps that asked the user for multiple pieces of information in one step. In the lego blocks architecture, we have separated the step and data point concepts. A step can be connected to 0…n data points. Each data point is assigned a UI, whether that be a text input or a multiple choice question. A step is then connected to the data points it wants to collect, a next button is added, and then you have your question! This allows the developer to create very simple or complex steps by aggregating multiple sub-components responsible for one data point. We chose to split most of our data points into separate steps, but with this architecture you have the flexibility of having as many as you need on one step.
This screenshot is the first step of signup where we decided to ask for first name, last name, and email on one step using text inputs.
This is the industry step that is a drop down of 100+ industries that a user can choose from.
This is the company size step which is a multiple choice button question.
To coordinate what steps are asked on a flow, we use Redux to manage our global app state, this acts as the source of truth for our application. Using the state we hold information like the flow structure, each data point collected within the flow, the authentication information, API request responses, etc. Anything that is important to the signup process is held within the state. Every React component then has access to the state if it is required. Using React hooks like useSelector grants the components access to whatever piece of information they need.
For example, we have a Next Button Component that allows users to progress through the flow. The component is connected to the state and is listening to the validation status of each data point that is being collected on that step. Once each data point has passed validation, it will enable and the user can move forward. This completely decouples the Next Button from any input fields and allows the component to be flexible and be reused on every step.
For each step, we can customize what messaging the user sees. We have some defaults for each step but each flow can be configured with specific messaging for each step. This allows for a personalized flow to the product the user is signing up for. The messaging for each flow is a static configuration but can be chopped and changed depending on user action, query parameters, etc.
To allow for all of this flexibility and personalisation, we use Redux within our application. Redux can be used as a data store for any UI layer and it is commonly used with React. The data store is the global app state. Within Redux, actions are dispatched. Those actions go through every middleware that is configured within the store. Then, the action hits the reducer, which updates the global state. Generally middlewares are used for hitting APIs or dispatching other actions.
In our application we rely heavily on middlewares. We have a total of 23, each with a different functionality and a different responsibility. Each middleware responds to specific actions that are dispatched by components or by other middlewares. All of our key signup logic is triggered by our middlewares. For example, we have a Validation Middleware that responds to the UPDATE_DATA action and runs the synchronous/ asynchronous validation for the particular data point. Then the state is updated with the validation status of that data point so the UI can be updated accordingly.
Our biggest and most complicated middleware is our HTTP Request Middleware. All API calls are controlled by one middleware. APIs can be triggered by state checks, by redux actions, before certain steps, or after certain data points are filled, and they are never called directly from within a component. Each time a new action hits the HTTP Request Middleware, it starts to check whether any API can be called at that moment in time. This is what allows us to decouple the API logic completely from the UI.
To trigger the sending of this particular API, we have three checks:
- getRequestData - we wait until all pre-verification data points have been completed and are valid.
- stateCheck - we make sure the user has chosen to verify their email using this method rather than with google.
- nextStep - we ensure the next step in the flow is one of our verification steps.
This logic is not tied to the UI at all, it is only tied to that global app state. This means we can transform the flow by adding or removing steps without having to worry about when this API will be called. All of our API requests are set up in the same way which gives us consistency across our codebase.
Another cool feature we have in our lego blocks architecture are the flow branches. For each step in the flow, we store the current step, an array of next steps, and all previous flow state within the global state. This gives the easy navigation back and forth through the flow.
Using a flow branch, we can transform the flow on the fly, based on query parameters, user input, you name it. Similar to our HTTP setup, each branch has a few checks which once pass, the branch can be taken. The checks we have here are:
- stateCheck - this can check anything necessary within our global app state.
- afterData - a check that makes sure the branch is only processed after a certain data point has been collected.
- afterStep - a check that makes sure the branch is only processed after a certain step has been completed.
In the example above, we can inject a Job Role step into the flow after the Industry step, only if the user has chosen the real estate industry. This flow branch feature gives us a massive opportunity to personalize our signup flow and it’s something we are continuing to explore. With our flexible architecture, this is now a 1-day change rather than a multi-week change.
How did we switch from one code base to another?
So we’ve created a brand new code base for our signup flow. How do we get real users onto it? We first put our original code base into “maintenance mode”. That means we didn’t push out any new features onto that code base during this migration. We did all essential work but nothing new was added there.
Our migration process was slow & steady and we certainly learned a lot. Here’s a few of our learnings:
- Don’t underestimate the scoping phase. Start small and take your time. Scope out that overall idea of how you want the architecture to look and don’t get bogged down with minor details just yet. You need to focus on the bigger picture. When we created our overall idea, we could always go back to it and say “this is how the architecture works, how can we fit the new feature into it?”. This has given us a really consistent codebase as that “lego blocks” idea can be seen throughout.
- Once you start coding, it can be really easy to get overwhelmed with how much needs to be done. If you start writing piece by piece, you can focus on how you can make each piece or each feature flexible and scalable to the bigger picture. We started our architecture with simple step navigation. Then we moved on to some data points, some UI, then some APIs up until we had a fully functional signup flow. From there, we could focus on making it look good. Breaking up the process into manageable goals was essential. We focused on flexibility at every step, and because of this, once we had one flow up and running, it became a 1-or 2-day change to add a new flow.
Starting from scratch is never an easy decision. It can be hard to let go of old practices and branch out. It’s also difficult to commit to a project as big as redesigning everything your team owns. But, now that we have, I don’t think we would ever go back to our old code base. We have a flexible architecture, we can continue to experiment with our signup funnel with ease, and we have less worry that we are going to cause any issues. Even though the project took us a total of 15 months to complete, it was very much worth it.
A call to action for you: take a look at your application. Is your code working, but not well organized and inconsistent? Is it error prone and risky to change? Why not imagine what your ideal architecture would look like and how many opportunities it could give you and your team? Maybe starting from scratch is going to be the best decision your team could make.