Understanding the difference between declarative and imperative UI design
The beauty of technology is that it evolves with time based on feedback about developer experience. Today, if you’re in mobile development, there is a high chance that you have heard about Jetpack Compose, SwiftUI, React Native, and of course Flutter. The thing these technologies have in common is both that they’re used for creating mobile applications and the fact that they do it via a declarative programming approach. You may have heard this term before, but what does it actually mean and why is it important?
To take full advantage of a framework, it’s important to understand its paradigm and work with it rather than against it. Understanding the “why” behind the architectural decisions makes it much easier to understand the “how,” and to apply design patterns that complement the overall system.
Native mobile platforms have a long history of development and major transitions. In 2014, Apple announced a new language, Swift, that would replace the current Objective-C. In 2017 the Android team made Kotlin the official language for Android development, which would gradually replace Java. Those introductions had a hugely positive impact on the developer experience, yet they still had to embrace the legacy of existing framework patterns and architecture. In 2019, Google announced Jetpack Compose and Apple announced SwiftUI – completely new toolkits for building UIs. Both SwiftUI and Jetpack Compose take advantage of their respective languages, Swift and Kotlin, leaving legacy approaches behind. Both toolkits also loudly boast their declarative programming paradigm. But language advantages aside, let’s explore why declarative is now the industrial de facto and what is wrong with imperative.
Understanding the imperative paradigm
By definition, the imperative programming paradigm focuses on how to achieve the desired result. You describe the process step by step and have complete control of the process. For example, it could result in code such as this:
fun setErrorState(errorText: String) { val textView = findViewById<TextView>(R.id.error_text_view) textView.text = errorText textView.setTextColor(Color.RED) textView.visibility = View.VISIBLE val button = findViewById<Button>(R.id.submit_button) button.isEnabled = true val progressView = findViewById<ProgressBar>(R.id.progress_view) progressView.visibility = View.GONE }
In the preceding snippet, we imperatively described how to update the UI in case of an error. We accessed the UI elements step by step and mutated their fields.
This is a real example of code that could’ve been written for a native Android application. Even though this approach may be powerful and gives the developer fine-grained control over the flow of the logic, it comes with the possibility of the following problems:
- The more elements that can change their presentation based on a state change, the more mutations you need to handle. You can easily imagine how this simple
setErrorState
becomes cumbersome as more fields need to be hidden or changed. The approach also assumes that there are similar methods for handling a progress and success state. Code such as this may easily become hard to manage, especially as the amount of views in your app grows and the state becomes more complex. - Modifying the global state can produce side effects. On every such change, we mutate the same UI element and possibly call other methods that also mutate the same elements. The resulting myriad of nested conditionals can quickly lead to inconsistency and illegal states in the final view that the user sees. Such bugs tend to manifest only when certain conditions are met, which makes them even harder to reproduce and debug.
For many years, the imperative approach was the only way to go. Thankfully, native mobile frameworks have since started adopting declarative toolkits. Although these are great, developers who need to switch between paradigms inside of one project can encounter many challenges. Different tools require different skills and in order to be productive, the developer needs to be experienced with both. More attention needs to be paid to make sure that the application that is created with various approaches is consistent. While the new toolkits are in the process of wider adoption, some time and effort are required until they are able to fully implement what their predecessors already have. Thankfully, Flutter embraced declarative from the start.
Understanding the declarative paradigm
In an imperative approach, the focus is on the “how.” However, in the declarative approach, the focus is on the “what.” The developer describes the desired outcome, and the framework takes care of the implementation details. Since the details are abstracted by the framework, the developer has less control and has to conform to more rules. Yet the benefit of this is the elimination of the problems imposed by the imperative approach, such as excessive code and possible side effects. Let’s take a look at the following example:
Widget build(BuildContext context) { final isError = false; final isProgress = true; return Column( children: [ MyContentView( showError: isError, ), Visibility( visible: isProgress, child: Center( child: CircularProgressIndicator(), ), ), ], ); }
In the preceding code, we have built a UI as a reaction to state changes (such as the isError
or isProgress
fields). In the upcoming chapters, you will learn how to elegantly handle the state, but for now, you only need to understand the concept.
This approach can also be called reactive, since the widget tree updates itself as a reaction to a change of state.
Does Flutter use the declarative or imperative paradigm?
It is important to understand that Flutter is a complex framework. Conforming to just one programming paradigm wouldn’t be practical, since it would make a lot of things harder (see https://docs.flutter.dev/resources/faq#what-programming-paradigm-does-flutters-framework-use). For example, a purely declarative approach with its natural nesting of code would, make describing a Container
or Chip
widget unreadable. It would also make it more complicated to manage all of their states.
Here’s an excerpt from the build
method of the Container
describing how to build the child widget imperatively:
@override Widget build(BuildContext context) { Widget? current = child; // ... if (color != null) { current = ColoredBox(color: color!, child: current); } if (margin != null) { current = Padding(padding: margin!, child: current); } // ... }
Even though the main approach of describing the widget tree can be viewed as declarative, imperative programming can be used when it feels less awkward to do so. This is why understanding the concepts, patterns, and paradigms is crucial to creating the most efficient, maintainable, and scalable solutions.
If you are coming from an imperative background, getting used to the declarative approach of building the UI may be mind-bending at first. However, shifting your focus from “how” to “what” you’re trying to build will help. Flutter can help you too, as instead of mutating each part of the UI separately, Flutter rebuilds the entire widget tree as a reaction to state changes. Yet the framework still maintains snappy performance, and developers usually don’t need to think about it much.
In the next section, let’s take a closer look at the abstraction to understand how the what actually works. We will explore not only how to use the widgets as a developer but also how the framework efficiently handles them under the hood. We will cover what to do and what not to do to avoid interfering with the building and rendering processes.