Bug-Free Code That Speaks for Itself: Principles of State Management
Bug-free Code that Speaks for Itself: Principles of State Management
Engineering @ NoomWelcome to the first instalment of our series on the state management principles that we have adopted at Noom. Since we implemented them, they have helped us write cleaner code, suffer fewer bugs (especially of the kind that drive you crazy), and keep our fast development speed even as the size of team grew. In this series, I will take a look at what we consider state, how we manage it, and what tools or frameworks we use to implement the principles we have adopted.Whenever you think about the way you should build an application that offers some kind of user interface, you typically start by building a mental model of how all the different components interact with each other. You draw mockups and flow diagrams that will hopefully help you understand how everything is supposed to work together. Maybe you even throw together a design document and pair with a technical outline. Inevitably, however, as your system gets more complex, it becomes harder and harder to follow all these specs, until you eventually decide you need to refactor everything because you no longer understand what’s going on!If this sounds familiar, you’re not alone. Writing anything by the simplest client application, regardless of whether it’s for the Web, a desktop operating system, or a mobile platform, involves a high level of complexity that escalates quickly and unexpectedly as your codebase matures.The main tactic used to tackle this complexity is to decouple components as much as possible, usually by following SOLID principles. The idea behind this approach is to gain the ability to reason about these components in isolation, so that when you are backtracking a bug, the mental diagrams that you have to follow to understand what’s happening are more manageable.In recent years, managing the state of your app has become a popular topic that has spawned a whole industry of new tools. On the Web, React has Redux, and Vue has Vuex, and similar patterns have emerged in the mobile world, thanks to libraries like RxSwift and RxJava. These tools all share the same goal: helping you design your application in a way that’s easy to mentally map and follow.Let’s see how that can work in practice, starting with the way we think about unexpected conditions—for example, Java’s null pointer exception (NPE). Imagine that you are implementing a profile screen like this one:Let’s first assume that the Bio field is optional so the user can leave it empty, which is represented inside our app by a null value. If you later try to use the bio of a user who hasn’t specified it and you forget to check if it’s null, you’ll get an NPE. This is a simple mistake, and one that is easily fixable. All you need to do is check for null in your code, and provide a default value if the bio is not there.Now, let’s assume that the Bio field is not optional. Your product requirement is that the user must provide it, and so you set up your UI components so that they don’t accept empty input. However, you could still get a null bio if, for example, you were loading the data to display from a database and the record stored there has an empty bio field. You now have a much bigger problem than before: something that you thought would never be null is null.Checking for null at that point is just a hack; if you decide that’s the way to go, you have to pepper your code with if-then-else conditions everywhere, because you can’t trust that the information that describes your app’s behavior conforms to the rules you have set out for it.Instead, you decide to find what caused the data to be corrupted in the first place. How did the user pass the flow without providing the bio? Maybe the bio wasn’t correctly stored, or the database was deleted or corrupted, or the requirements simply changed, and the old data hasn’t been modified accordingly. There are so many possibilities, and finding the root cause is often not easy.Let’s get back to our profile screen and try to introduce the concept of state. The main purpose of this screen is to collect the user’s information. Because we want this data to be as accurate as possible, we have to make sure that what the user enters is as accurate and complete as possible — the age shouldn’t be a negative number, the name cannot be empty, and the height has to be in a reasonable range. This means that we have to put some validation in place and display proper error messages if it fails.Once a user submits valid data, we have to store it in our database (or upload it to our server—or, often, both). This is usually a slow operation, so we have to disable the submit button and show a spinner until the process is complete.All of the data and rules needed to present the UI at a specific point in time, all the different error messages, flags that indicate if we should show a spinner or disable a button, are considered its state, and they—not the UI—truly describe how our app behaves given a certain set of inputs.When it comes to managing this state, we have a set of many best practices that we rely on. Here are two important ones:Model state explicitly as a hierarchy of classes.Model state transition explicitly with reducers.The main driver behind these principles is very simple: to understand what a component is doing, you have to follow the code, mentally mapping this component’s interactions with all of the other components.For a complex system, keeping track of all of this in your head is, to say the least, hard to do. The naïve approach of mixing state management with the user interface code starts simple and seems convenient, but very quickly becomes unmanageable as the complexity of the application itself grows: just as you need state management to become easier so that you can focus on new features, it turns into a mess of buggy code that gets duplicated over and over and becomes impossible to maintain.To avoid missing something critical, modern applications keep state and UI code separate, so that all the code that describes what data they need and how it changes their behavior is explicitly stored in a single, convenient location.As you can imagine, you will have to write a bit more code this way; this makes this technique a little counterintuitive, which is probably one of the reasons why so many developers approach it with suspicion. However, it will help you to simplify your reasoning and make the system more understandable — which brings us to our first state management best practice.Principle 1: Model State Explicitly as a Hierarchy of ClassesLet’s dig deeper into our profile feature. The requirements are simple: the user should be able to add or update their profile information. The default screen would look like this:The data that we want to collect is gender, name, age, height, unit, and bio. We also have two business (validation) rules. First, everything except bio is required, and second, the user must be at least 18 years old.Let’s think about the flow of the app:When the user opens this screen, we start fetching profile information from the database (or from our API). During that time, we could show a spinner and a nice message like “fetching data…”When the data is fetched, we pre-populate the form and let the user make updates.When the user clicks submit, we disable the submit button and validate the data against our business rules.If the data is valid, we store it in the database, or send it on to our API, in which case we could display the spinner again.If the data is not valid, we display error messages.Once we’re done, we dismiss the screen.We could write the implementation in a way that closely follows this line of reasoning. For example, in Android using Kotlin (though the same would work similarly in iOS with Swift, or in a modern reactive framework in JavaScript), we could add an OnClickListener handler where we ask the UI components to give us data, validate it, and store it directly into our database. If validation fails, we simply change the UI to display error messages. Here’s what the code could look like:There is no explicit mention of state or separation of concerns, but the code works just fine. Frankly, it’s probably as good as it needs to be, and, if I came across it in a code review, I wouldn’t reject it out of hand (although I would probably make a note that we should expect to refactor it soon). After all, the feature is simple, and so reasoning about its correctness is not hard.However, it’s important to notice that it’s not easy to break this feature into smaller components and reason about their correctness separately, which means that its complexity is not going to scale linearly as we add more functionality. Eventually, we’ll just get into a situation in which we can’t handle things with just one component, at which point the behavior rules that we have so carefully crafted here will have to be spread among multiple components, leading to duplication and confusion—and, of course, bugs that are very hard to figure out.As an exercise towards a better solution, let’s try to list (and name) all possible states in which our screen can find itself.First, we are fetching the profile from the DB — let’s call this state FetchingProfile.Once the profile is fetched, we’re pre-populating the form, showing the UI and enabling the user to update fields. Let’s call this state ShowingProfile. We’ll see later that this state is responsible for showing validation messages as well.When the user clicks the submit button we validate the data; let’s call it ValidatingProfile.Once the profile is validated we’re storing it in the DB , so we’ll go with StoringProfile.Finally, once we’re done we want to dismiss the screen . Let’s call this state FinishingProfile.Now that we have listed all of the different states, we can easily model them in our app:Note that this code is not at all concerned with what the application looks like. It simply describes an abstract representation of its behavior, and then makes it available to other actors (which we call feature components) so that they, in turn, only have to focus on three actions: observing the state, reacting when it changes, and modifying it. For example, our UI component could:Show the spinner if the state is FetchingProfileRemove the spinner and enable the submit button, populate the UI with the values from the state properties, and change the state to ValidatingProfile when the submit button is clicked, if the state is ShowingProfileDisable the submit button if the state is ValidatingProfileShow the spinner if the state is StoringProfileDismiss the screen if the state is FinishingProfileHere’s what this could look like in practice. I simplified this code a bit for convenience, but you can hopefully follow its logic:Next up, we could write a database component, which will observe the state and:If the state is FetchingProfile, retrieve the profile information from the DB, and then change the state to ShowingProfile.Do nothing if the state is ShowingProfileDo nothing if the state is ValidatingProfileStore the new profile information if the state is StoringProfile and change the state to FinishingProfileDo nothing if the state is FinishingProfileFinally, we can write a validation component. Its job will be to kick in only if the state is ValidatingProfile, apply the appropriate validation rules to each property, and, if they pass, transition the state to StoringProfile. Otherwise, it will decide which error messages to report, and transition to ShowingProfile with the nameError, ageError, and heightError properties correctly populated.Even though we ended up with a little bit more code (because moving from one state to another takes a couple of lines), notice how we decoupled all our components. The UI doesn’t know about validators anymore. It only knows how to present the current state — if the nameError is not null, it will show the message, without caring about where the value came from. Reasoning about the UI component correctness is now easy, and testing is just a matter of feeding a series of synthetic states that exercise each part of the interface. With our previous code, testing would have meant having to mock up a database, feeding it various combinations of data that pass the validation rules or cause them to fail, and so on. Worse yet, we would have only been able to test everything together: if a test fail, it’s harder (though by no means impossible) to tell whether it does because of the database, data storage, validation rules, or who knows what else.Another very important benefit is readability. For someone who hasn’t seen the feature, it’s enough to look at the state management component to get a general idea of what’s happening. You can easily see that this feature has something to do with displaying the user’s profile and the ability to store and validate it. This would not have been possible with our initial implementation — you have to read all the code to understand what’s going on. The importance of this will become even more obvious once we introduce the second principle; at that point we’ll have both explicit state and explicit state transitions, which make it possible to readily understand how the state changes based on events that components emit.Next StepsWith Principle 1 in place, we’re able to decouple our components. This allows us to reason about them in isolation, but not as part of a complete application workflow: because the components are changing the state directly, they affect each other in ways that can be hard to predict and model, making our application prone to complex failure modes when the model changes in ways that we have not anticipated.In the next article, we’ll see how modeling our transitions explicitly will help us eliminate this problem, and help prevent some other potential issues along the way. Follow us to find out when it becomes available!Like what you see? Noom is hiring—come help us make the world a healthier place for everyone.