Legacy-proof UI: Part 3—How to make UIs testable and easy to change?
Table of Contents
Over the course of these articles, I am reflecting on why UI code is much more prone to become legacy as opposed to other types of software. As I identify the reasons behind this phenomenon, I propose an approach — not the most obvious one, but, as I will do my best to justify, is key to opening the door to making UIs legacy-proof and scalable as well as offering extra perks.
- Part 1 — Why does code become legacy?: By using a simple metaphor, I try to illustrate the problem and use it as a reference for the entire series
- Part 2 — Why is UI development legacy-prone?: Another illustration that builds on top of the previous one, further clarifying the problem
- Part 3 — How to make UIs testable and easy to change?: A concise survey of known approaches to UI in the context of the problem outlined in the previous articles and where they fall short as well as proposing a new solution
- Part 4 — React as a decoupled stateless view in Storybook: In this article, we will consider an example of what it means to have a stateless view separate from business logic.
- Part 5 — MVU architecture in a React application: Example of how to make a completely stateless view interactive
- Part 6 — From Figma to React, Vue, Svelte, Preact, and Beyond: Exploring the legacy-proof (adaptability) benefits of having a completely separate view that matches designs closely
- Part 7 — Decoupler and the future for legacy-proof UI: Further exploration of perks that this approach allows that go even beyond legacy-proof code considerations
How to make UI testable and easy to change?
In the previous articles, we arrived at the conclusion that in order for code to not become legacy there needs to be quick correctness feedback and good patterns in place.
Given these two conditions will exhibit the ability to be changed easily.
However, there exist problems with current UI approaches that make these two conditions difficult to achieve.
Problems with architecture and design patterns
React and similar view libraries
React and many other similar front-end libraries tend to fall into the same trap. They tightly couple business, IO, and state management logic.
You develop your components with hot reloading, adding server requests (IO) logic inside of the body of the component, as well as adding redux or similar hooks. Not only it is very convenient, but also allows you to develop something that works and do so very quickly.
This works well for small projects. But this does not work for development at scale.
By allowing you this flexibility, you introduced everything that makes you view layer not just the view layer, but a mix of various layers, and it’s now going to be hard to turn back.
Surprisingly, React was introduced as a view library, that was intended for only handling the view, but in reality, it rarely does handle just view as hooks have now taken over the world.
Survey of architectural approaches
The problem of tight coupling of logic with view is not new and there have been many approaches to solve this problem.
Related frameworks: Ruby on Rails (Ruby), Django (Python), Laravel (PHP), Spring MVC (Java), ASP.NET MVC (C#)
The MVC pattern is perhaps the most classic architectural pattern in UI development. This pattern separates application logic into three interconnected components:
- The Model manages the data, logic, and rules of the application.
- The View represents the visualization of the data that the model contains.
- The Controller accepts inputs and converts them to commands for the Model or View.
The issue with MVC is that it can often become convoluted as the application scales, with the controller handling a significant portion of the logic. It becomes challenging to manage and test due to the increased dependencies.
Related frameworks: GWT (Java), Vaadin (Java)
MVP is a derivative of the MVC architecture and is mostly used for building user interfaces. In MVP:
- The Model is the data layer.
- The View is the user interface.
- The Presenter acts as a bridge that connects the model to the view.
The Presenter, unlike the Controller in MVC, also decides what happens when you interact with the View. This architectural pattern doesn’t really decouple the view from the rest of the application as the view maintains a reference to the presenter and calls its methods as the user interacts with the view. Changes to either presenter or view most likely will affect each other.
MVVM is another derivative of MVC where the controller is replaced by a ViewModel. The ViewModel is a simplified Model that prepares data for the View, meaning it decouples the View from the Model. This approach facilitates two-way data binding between View and ViewModel which makes automated UI testing more straightforward than in MVC or MVP. However, maintaining synchronization between View and ViewModel can be complex and error-prone. And it also doesn’t do as good of a job of making the view layer completely unaware of the rest of the application.
Popular frameworks: Elm (Elm Language), Fabulous (F#), SwiftUI (Swift)
The MVU pattern, popularized by The Elm Architecture, is a relatively new approach to front-end development. In MVU, the model defines the state of the application, the view renders the UI based on the state, and the update function modifies the state based on messages (like user actions or server responses). This unidirectional data flow ensures that the UI is predictable and easier to debug.
Popular frameworks: Original Flux, Redux, Alt.js, RefluxJS, Marty.js, McFly, Fluxible, Delorean, NuclearJS
Flux is an application architecture that Facebook uses for building client-side web applications. It complements React’s composable view components by utilizing a unidirectional data flow, making the application’s behavior more predictable and easier to understand.
Components of Flux Architecture
- Action: These are payloads of information that send data from the application to the Dispatcher.
- Dispatcher: A central hub that manages all data flow in the application. It is essentially a registry of callbacks into the stores.
- Store: This contains the application state and logic. They are somewhat similar to models in a traditional MVC pattern but manage the state of many objects.
- View: The final output of the application based on the current state of the Store.
In Flux, user interactions, server responses, and form submissions are all examples of Actions. The dispatcher processes these Actions and updates the Stores. The View retrieves the new state from the Stores and updates the UI accordingly. This unidirectional flow (Action -> Dispatcher -> Store -> View) is similar to MVU’s (Model-View-Update) data flow where the user input generates a message, the Model updates based on the message, and the View is a function of the Model.
Redux builds upon the Flux architecture but simplifies it by enforcing a few rules:
- Single Source of Truth: The state of your whole application is stored in one object tree within a single store.
- State is read-only: The only way to change the state is to emit an action, which is an object describing what happened.
- Changes are made with pure functions: To specify how the state tree is transformed by actions, you write pure reducers.
These rules help maintain consistency and predictability within the application, making it easier to track state changes and debug the application.
Redux is closer to MVU than traditional MVC. Redux, like MVU, uses a unidirectional data flow where an Action (analogous to MVU’s “message”) triggers a change in the application’s state (Redux’s “single source of truth” is similar to MVU’s “Model”), and the View is updated based on this new state.
While these approaches provide a structured way of developing UIs, they come with their own sets of challenges.
While none of these approaches is a magic wand, I have to clearly state that the philosophy behind these articles is based on the assumption — gained from the insight from years of experience — that the tight coupling of the view layer to the rest of the application is the root of all evils and, therefore, will be exploring the only approach that allows overcoming this — the MVU pattern.
Elm is a much better solution for front-end development in terms of architectural design patterns, particularly for its ability to completely decouple the view as a pure function of state via its use of Model-View-Update (MVU) pattern.
Unfortunately, even though it’s a great tool, it lives in a completely different world with a very peculiar language and isolated ecosystem, it exhibits an unattractive pattern of not being able to opt-in gradually (like with TypeScript), which makes it hard to jump on the bandwagon as it would vendor-lock you in.
If Elm wasn’t a tightly coupled combination of an ML language, framework, and MVU pattern all baked into one, enforcing all-or-nothing choice, I would have been just exploring Elm.
Because Elm is a really great technology, of course, there were attempts to introduce similar patterns that Elm relies on, like unidirectional data flow, and similar decoupling of IO, with Redux.
Elm and Redux are similar in many ways, as both implement a functional programming style and a unidirectional data flow, but they have different approaches when it comes to connecting the view to the application’s state.
In Elm, the pattern is Model-Update-View. The whole model is passed to the view function each time an update occurs, meaning the entire state of the app is available when rendering the view.
Redux, on the other hand, is more flexible and less prescriptive about how you connect your state to your views. With Redux, you can use the connect function (when using React-Redux) to bind just the part of the state that the specific component needs, rather than the entire state.
In Redux, however, this makes view tightly coupled to the rest of the loop, so you can’t swap the view easily, without disentangling it properly, which is very tricky and no fun. Another way both Redux and Elm tightly couple view and the rest of the loop is by dispatching actions from controls (UI elements) thus connecting to the logic.
For example, if you have a button that is supposed to increment a value by one, you name an action “increment” and add that to the handler of a button.
However, if later on you decide, to change the underlying logic to use “multiply”, you would have to go to the view and change it.
But view shouldn’t really know about business logic, as well as business logic shouldn’t know about the view.
Therefore, actions should be separated from the view layer.
Each known architectural approach has many prescribed design patterns. I will not go in-depth on them but will attempt to provide some resources at the end of the article.
Problems with testing
In the previous articles, we mentioned that design patterns help to partially solve the problem of the tendency behind code becoming legacy, a much more important task is the quick correctness feedback in a black-box manner. MVU is going to prove quite helpful in ensuring that such black-box feedback is easy to obtain.
React, as well as many other frameworks, have various solutions for testing, however, there are also very important shortcomings.
For libraries that rely on running React under the hood, as RTL does, tests become integration tests.
And since they are integration tests, the setup might become very complex. The setup for the RTL library itself is straightforward, but the procedure of mocking the dependencies can become unwieldy for larger projects.
And since we mentioned that requirements will change often, the tests might also become obsolete, so we would need to be able to adapt quickly, which could be quite difficult when we have to be aware of many dependencies.
In the context of the metaphor of a line with dots, our tests would be equivalent to locating the dots, and at some point, this process might get too complex and unwieldy making it a burden rather than the solution.
Regarding Redux, even though it’s a library that (finally) allows you to have a unidirectional pure-function-like flow, I haven’t seen a single test written to test its behavior in real-world applications.
How can we do better?
To improve the situation, we should separate business logic from view. This would allow us to test both (very complex) parts of applications separately, and be able to swap one part without touching the other.
This approach would allow us to put the entire view in Storybook, where we would be able to see it in different states easily. Would also allow us to do visual snapshots that would help us with refactoring.
Regarding the business logic, if it follows the unidirectional data flow and is separate from view and other IO, we could write pure-function black box tests that would let us easily check if everything is correct.
Very important to note, that once we have these black-box tests we can refactor our code, implementing the necessary design patterns that would allow us to keep our logic from becoming jumbled.
This would then allow us to refactor the underlying code accordingly and would allow us to do timely refactoring and introduction of necessary design patterns — one of the most important aspects of legacy-proof code.
On top of that, we could then connect this business logic and the view in Storybook again to see how they interact.
Overall, this approach would be highly storybook-friendly, which, in turn, means that the most important part about developing legacy-proof code would be out of the way — quick feedback.
As we mentioned earlier, in order for code to not become legacy there needs to be quick correct feedback and good patterns in place.
Given these two conditions will exhibit the ability to be changed easily.
I wanted to demonstrate that there exists an inherent problem with coupling in UI development that makes these conditions hard to achieve.
This problem makes it difficult to adapt code as well as get quick feedback.
Among the known architectural solutions, only the MVU pattern helps to provide a strong separation of view from the rest of the application, which is an important consideration and aligns best with the philosophy behind these articles.
However, the current solutions relying on unidirectional flow still exhibit a high coupling of business logic and view, and this needs to be resolved if we want to be able to get quick feedback as well as adapt quickly.
Over the course of the next articles, we will attempt to resolve this problem.
Part 4 — React as a decoupled stateless view in Storybook: In this article, we will consider an example of what it means to have a stateless view separate from business logic.
- Survey of Common Architectural Approaches: Useful article gives a brief overview of the major architectural approaches in UI
- Problem with MVP: Article that explores shortcomings of MVP pattern
- Design patterns — A comprehensive guide to software design patterns. Each pattern is explained in detail with examples to help understand when and where to use them.
- SOLID Principles: Explanation and examples — This FreeCodeCamp post breaks down the SOLID principles in an easy-to-understand way with lots of examples.
- React Documentation: The official React documentation is a must-visit resource for anyone working with React.
- Redux Documentation: The official Redux documentation provides a comprehensive guide to getting started with Redux, as well as more advanced topics.
- Elm Language Guide: This is the official guide to the Elm language, providing a detailed look at the language’s syntax and features.
- React-Redux connect function Documentation: This part of the Redux documentation gives specific information about the ‘connect’ function, which is critical to understanding the connection between state and views in Redux.
- Testing React Applications: The React Testing Library (RTL) documentation offers a guide on how to test React components.
- Storybook for React: Storybook is an open-source tool for developing UI components in isolation. This link leads to the specific documentation for using Storybook with React.
- Model-View-Update (MVU) pattern: This page of the Elm guide explains the Model-View-Update (MVU) pattern, which is a key concept in the Elm language.
- Understanding Unidirectional Data Flow in React: This article explains the concept of unidirectional data flow in React and Redux.
- Clean Architecture for SwiftUI
- SwiftUI TEA