Towards A „Perfect“ GUI Toolkit
For over 40 years, building graphical user interfaces (GUIs) has been one of the most difficult problems in software engineering. Despite a constant evolution of languages, libraries, and design patterns, developers face the same fundamental problems: managing state, synchronizing data with the view, and enabling communication between distant components.
The root of this struggle lies in a core conflict that nearly every toolkit fails to properly address: the conflict between the Visual Tree and the State Graph.
- The Visual Tree is the hierarchy of elements as they appear on the screen. It is always a tree: a window contains a panel, which contains a button. Its structure is defined by layout and presentation.
- The State Graph is the map of how application data and logic are connected. A filter control
in a sidebar (
Visual Tree->Sidebar->Filter) needs to alter the data displayed in a completely separate table (Visual Tree->MainPanel->Table). A „Save“ button in a toolbar must know if form data, located elsewhere, is valid. This network of dependencies is a complex graph, not a simple tree.
The history of GUI development is the history of failed or incomplete attempts to reconcile these two different structures. The „pain“ of UI programming stems from frameworks that either fuse them together or awkwardly force the graph to conform to the shape of the tree.
First Gen: Fused Hierarchy (OOP)
The first generation of toolkits (Qt, GTK, MFC, Swing) was built on an object-oriented
model. The paradigm was simple: the UI is a tree of stateful objects. A Button object
holds its own text and state, a MyCustomPanel object inherits from Panel and adds
its own data and logic.
# OOP Paradigm Model
# ...
=
=
In this model, the Visual Tree and the State Graph are fused. The object inheritance hierarchy is the visual hierarchy. This immediately creates real problems:
- Communication between logically related but visually distant components requires complex pointer management, global mediator objects, or a web of signal-and-slot connections that are difficult to trace and maintain.
- Changing the visual layout in this paradigm forces a refactoring of the class hierarchy, which makes developing applications in such toolkits painful and creates hard dependencies on the toolkit itself (leading to online „toolkit wars“, GTK vs Qt). The application logic is not testable in isolation because it is fundamentally inseparable from the UI objects themselves.
- It creates a hard dependency on the toolkit itself. Your application logic is not portable or reusable because it is fundamentally intertwined with the toolkit‚s base classes, rendering system, and event model.
Second Gen: Constrained Hierarchy (Elm, React)
The next major step, led by frameworks like React and the Elm Architecture, introduced a new
functional paradigm: UI = f(data). The UI is a declarative, pure function of the application's
state. This was revolutionary at the time, as it solved the problem of state synchronization. When
the data is changed, the framework efficiently updates the view to match instead of manually
needing a setText() call.
# React Paradigm Model
, =
, =
=
return
However, while these frameworks finally decouple the view from imperative manipulation, they
still constrain the flow of data to the shape of the Visual Tree. The example above works
because TextInput, Button, and Label are all siblings, children of MyApp. But what if
the Button were in a Toolbar and the TextInput and Label were in a MainContent panel?
React's solution is to „lift state up“ to their lowest common ancestor, MyApp. The MyApp
component must now hold the state and pass both the data and the callback functions down through the
intermediate components.
# State is lifted to the common ancestor
, =
# ... logic also lives in the ancestor ...
return
Here, the State Graph is still being forced into the tree structure of the view, leading to „prop drilling“ and components with indirect APIs. The existence of complex „escape hatches“ like Redux or the Context API is evidence of this core constraint—they are patterns invented to work around this default tree-based data flow.
Elms solution goes even further to „lift all state up“ to the root ancestor and route everything in a single, top-level „update“ function. Elm therefore represents the philosophical extreme of the constrained hierarchy:
- Model: The entire state of the application is held in a single, immutable data structure.
- View: A pure function that takes the
Modeland returns a description of the UI. - Update: A single, central function that is the only entity allowed to modify the state.
It does so by taking an incoming
Msg(a message from the UI) and the current state, and producing a new state.
Third Gen: Ignoring Hierarchy (IMGUI)
Immediate Mode toolkits (IMGUI) take a different approach. The paradigm is to have no persistent UI objects at all; the UI is redrawn from scratch from application data every single frame. This solves synchronization by brute force (but shoves the problem of application architecture onto the developer).
# IMGUI Paradigm Model
=
=
# Inside the main application loop, every frame
=
=
```
However, IMGUI doesn't solve the Visual Tree vs. State Graph problem—it largely ignores it and creates a hidden data binding in a „closure with captured arguments“ instead of a „class with state and functions“: the form is different, but the operation is the same. A closure is just a function on a struct containing all captured variables. The effect is the same as a class-with-methods, but on top of that, it provides even less layout flexibility than object-oriented code.
Immediate Mode GUI does solve the synchronization problem, but it fails at the other two core problems of GUIs: data access and inter-widget communication.
Why Electron Won
The thesis is this: The success of Electron is a consequence of this architectural superiority of Gen2 over Gen1 frameworks. In the 2010s, developers were moving to the declarative web paradigm not because it provided more features, but primarily because it was more maintainable than the 1990s-era OOP model. When tasked with building a desktop application, they had a choice: revert to the painful, fused hierarchy paradigm of Qt or GTK, or use the more modern (yet still constrained) hierarchy of React.
Electron provides the bridge - while many developers were probably unconscious about it, they chose it not for its stellar performance (or lack of it), but for its better paradigm. The native desktop world had no answer to this, so developers accepted the performance cost and tons of build-tool workarounds as a necessary evil.
Azul, however, is not an answer from the second era. It doesn't try to reinvent „Electron, but in Rust“ or
„React, but in Rust“. Instead, it tries to build a „Fourth Generation“ paradigm: acknowledging the idea of
UI = f(data) but not building on the wrong conclusion that the UI State Graph has to also be a tree like
the view hierarchy is.
But what exactly is a GUI toolkit?
So, how does a „GUI toolkit“ differ from just a „rendering library“? As shown above, one can mainly categorize the toolkit by its handling of the following three „hard GUI problems“:
-
Data Access / Model-View separation: Somehow a callback needs access to both the data model (i.e. the class) and the stateful UI object (to scrape the text out), but at the same time the „data model“ should be as separate from the UI as possible, so that logic functions do not depend on view data.
-
Synchronization: It is very easy for the visual UI state and the data model to go out of sync. Solutions so far include „Observer patterns“ (callbacks that run when something changes), React-like reconciliation or „just redraw everything“ (IMGUI).
-
Inter-widget communication: Existing toolkits assume that the widget hierarchy (visual tree) and the inheritance (or function call) hierarchy are the same (least common ancestor problem). If two UI objects that have no common parent have to talk to each other, you now have a massive problem.
Pure „rendering libraries“ do not solve these problems at all, instead shoving the responsibility onto the application programmer (aka. „not my job“). The result of such „freedom“ to design any application style is often sub-par, but enjoys a large popularity because it secures the job of whoever first wrote the application.
Starting again
So what would a „proper“ toolkit look like?
The first thing we‚d need to decide is whether we'd like to serialize the UI or render it directly, without storing it. Now, as computers got faster and rendering methods evolved, UI toolkits moved away from a direct render-to-screen to a display-list or a „list of commands“ for the renderer. Often times this command list is batched or computed against the last frame to minimize changes - while there is a small overhead, it is almost unnoticeable.
The only real „sane“ way here is to serialize the entire UI hierarchy and then perform layout, state and cache analysis in the toolkit. A good comparison is to compare XML to function call stacks - compare:
with:
Composing UI hierarchies via functions makes much more sense than composing UI hierarchies via inheritance because the latter is often language-specific and not supported in all languages, whereas functions are language agnostic.
Data access: Format and locality
The second decision is where to store the UI data, so that the callbacks may access it again.
Widget-specific data has to be either stored on the programmer side (in the application, using
inheritance or traits) or in the framework (either using data attributes or - worse - global state
modifying functions such as synchronous setColor(RED); draw(); swap(); calls). What format should we use?
Inheritance-based toolkits only allow one format: You have to inherit from a UI object and then construct
your application as a series of UI objects. Azul however, stores the application data as an
implementation-agnostic RefAny struct: similar to PyObject or Javascripts Object it just stores
„some data“, but the toolkit doesn't know what the type is. You can upcast your data and wrap it via
RefAny::new and then get immutable or mutable access again via .downcast_ref() or .downcast_mut(),
respectively:
let data = new; // owns the data
let data_clone = data.clone; // only bumps the reference count
let data_ref: &usize = data..unwrap; // ok
println!; // prints „5“
let data_mut: &mut usize = data..unwrap; // error: data_ref still held
// object destroyed here
Effectively this is similar to Observables, however, since RefAnys are connected to a Callback,
a Dom, a Task or a Thread, the topology of how they are connected is more obvious than
with a free-floating Observable, whose memory lives „somewhere“.
The biggest upside here is that this model makes the framework C-compatible (as Rust closures can never be expressed in the C ABI). The biggest downside of this is that we need an extra „upcast / downcast“ system, as well as heap memory allocation (earlier versions of Azul experimented with a „StackCheckedPointer“ to avoid heap allocations, but this proved to be far to mentally complex for developers and was also unsound).
Using [insert language]s module system, we can however control any errors related to up / downcasting by controlling the visibility of the thing we're downcasting to - effectively reducing the „blast radius“ that a type casting error could have:
// number_input.rs (private internals)
// number_input.rs (public API)
extern „C“
This way, once a decent amount of test coverage is done, the „internals“ of any widget
are hidden from the outside completely. When all references to a RefAny are deleted,
the internal object is deleted, too (running either a default or custom destructor).
Building a State Graph
The core mechanism for building the State Graph directly, instead of being dependent on
the Visual Tree is the backreference: a reference (RefAny + Callback) inside of
another reference, to pass data / callbacks of a higher-level data model directly down to
a lower-level component during DOM construction, without having to „prop drill“ any data / callbacks
through intermediary components / middleware.
Simple Example: Validated Number Input
To explain this new concept, let's build a number input that wraps a text input and validates
the input as a number. This demonstrates the backreference pattern in its simplest form — a
linear chain from low-level (TextInput) through mid-level (NumberInput) to high-level
application logic (AgeInput).
TextInput is the lowest-level widget that manages text and provides hooks for validation:
:
:
=
= None
# Allow higher-level widgets to hook into focus loss
=
=
=
return
# private to TextInput, updates TextInput.text internal state
+= .
return
# private to TextInput, calls the user-provided validation callback
# When focus is lost, invoke the user-provided callback if it exists
return
, =
return
NumberInput now wraps TextInput and adds validation logic. It again
holds a backreference to its parent (the application) via on_number_input:
:
:
=
= None
# Store a backreference to the application‚s callback
=
=
# Pass a backreference to *this* NumberInput down to TextInput
return
# This callback receives the NumberInput's data
return
=
return # Invalid input; ignore silently
# Validation passed! Now invoke the *application‚s* callback
, =
return
The top-level application logic is then completely decoupled from UI concerns, and can expect the NumberInput to call it back with a number, not a string (so the validation logic has already passed):
:
=
=
# Pass a backreference to the application down to NumberInput
return
# This callback only runs if the input was a valid number
return
=
return
=
When the user now finishes editing and the input loses focus, the event flows through the backreferences:
_on_focus_lost(RefAny<TextInput>, text_string)_validate_text_input_as_number(RefAny<NumberInput>, text_string)_on_age_input(RefAny<MyApplication>, validated_number)
Each level knows only about its immediate parent via the backreference. TextInput has
no knowledge of AgeInput, and AgeInput has no knowledge of the specific UI widget
being used. The State Graph is explicit: AgeInput → NumberInput → TextInput.
This pattern scales to arbitrary depth. You could create an EmailInput that wraps
TextInput and validates email format, or a CreditCardInput that validates card IDs.
Each layer simply adds logic without coupling to the layers above or below.
Complex Example: Non-Linear Hierarchies
The power of backreferences becomes even clearer with non-hierarchical state dependencies. Consider a node graph editor, where the logical connections between nodes (a complex graph) have no relation to their visual layout (a flat list of sibling elements on a canvas).
The challenge of a node graph is that the logical connections between nodes (a complex graph) have no relation to their visual layout (a flat list of sibling elements on a canvas). This breaks the core assumptions of almost every other toolkit - so let's see how Azul solves this problem.
Backreferences: The Clean Path
In the NodeGraph, when a user clicks an input port on a node, how does the widget tell the
top-level NodeGraph state to create a connection? It doesn't send a message „up“
the Visual Tree. Similar to the TextInput, it follows a pre-defined chain of backreferences:
- The
Domfor the input port has a callback holding aPortWidget's data. - This
PortWidgetcontains a backreference to its logical parent‚s data, theNodeWidget. - The
NodeWidgetin turn holds a backreference to the top-levelNodeGraphWidget, which contains the entire application state.
The callback for the click event on a visual node's Input / Output simply follows
this chain of references, making a direct jump from the event source to the top-level data model.
# Pseudo-code illustrating the backreference chain
# NOTE: These are not UI elements, but the logical controllers for them.
= # The actual application data
# Logic that lives at the top level
# ... logic to connect nodes in self.graph_state ...
=
= # Backreference to the graph
# This method is „lent“ to the PortWidget
:
=
= # Backreference to the node
# This would be the callback attached to the UI element
# Uses its backreference to start the logical chain
Wiring it all up:
# Top-level state and logic controller
=
=
# 2. Create controllers for child components, passing down backreferences
=
=
# 3. Simulate a user clicking the visual port
The flow of control follows the logical graph, not the visual tree:
Event->PortWidget.handle_click_event()PortWidget.handle_click_event()->NodeWidget.on_port_clicked()NodeWidget.on_port_clicked()->NodeGraphWidget.on_port_clicked()
This data flow is completely independent of the visual layout and the intermediary middleware flow
is completely hidden at the highest level. The PortWidget is perfectly decoupled; it doesn't
know what the NodeGraphWidget is, only that it must call a function on the reference it was given.
In the "real-world" NodeGraph, the flow is more complex, but the pattern scales horizontally, irrespective of the number of events that the graph needs to handle or the complexity of the graphs features.
A comparison with React
Azul, by its architecture, therefore solves a lot of problems that need workarounds in existing libraries, such as React, Elm or other frameworks:
Why useState + useEffect is unnecessary
React requires useState to create reactive state and useEffect to
synchronize side effects with that state. This creates a dependency
management problem where developers must carefully track which state
changes should trigger which effects, leading to bugs from stale closures
and infinite re-render loops.
Azul eliminates this entirely through explicit control flow:
- State is just data in a
RefAnystruct—no hooks needed - Side effects are explicit
Tasks spawned when you decide, not when React‚s reconciler decides - The
layout()function only re-runs when you returnUpdate::RefreshDom - No dependency arrays, no stale closures, no automatic re-renders
So, now how does Azul solve React's Problems?
- Callbacks hold live
RefAnyreferences, not captured values from render time, so no stale closures possible - You spawn
Tasks explicitly; they hold direct references to state, in difference touseEffectsynchronization - You control when effects run via explicit conditionals (
if state.needs_update), which gets rid of dependency tracking
Why the Redux/Context API is unnecessary
Redux and Context exist to escape React‚s tree-constrained data flow. They're architectural workarounds—evidence that the framework‚s default model is insufficient for real applications. Redux forces all state changes through a central reducer with action dispatching, while Context requires wrapping components in providers and dealing with re-render cascades.
Azul's backreferences make non-local state access a first-class citizen:
- A deeply nested component can hold a direct
RefAnyto the top-level state - No action creators, no reducers, no dispatch boilerplate
- No context providers or consumer hooks
- The State Graph is explicit in your data structures, not hidden in runtime context
So, Azul will never need a „Redux“ framework, because all of the problems are solved from the start:
- Prop drilling: Just pass a backreference once during construction, not props through every intermediate component
- Boilerplate: Direct mutable access via
.downcast_mut()instead of actions/reducers - Performance: No context re-render cascades; only components returning
Update::RefreshDomre-render - Type safety: Downcasting is type-checked; Redux actions are often stringly-typed
- Testing: Logic functions take
RefAnyparameters—fully testable without dependencies on Azul
FAQ from [...] developers
But what about performance?
This is where Azul tricks a bit. Due to its pure-functional nature, the Dom can in fact be pre-computed to
a const item, i.e. constructed at compile time. Using a separate tool, Azul can compile
HTML/CSS directly to Rust/C code. It looks a bit like this:
becomes (after compilation):
const CSS_MATCH_17553577885456905601_PROPERTIES: & = &;
const CSS_MATCH_17553577885456905601: NodeDataInlineCssPropertyVec =
from_const_slice;
const IDS_AND_CLASSES_9205819539370539587: & = &;
const LIST_VIEW_CONTAINER_CLASS: IdOrClassVec =
from_const_slice;
const LIST_VIEW_NEVER_CHANGES: StyledDom = div
.with_inline_css_props
.with_ids_and_classes;
extern „C“
This avoids doing the „CSS cascade“ at runtime and instead pushes it to compile time.
The AzString and FooVec types all allow you to create strings / arrays from compile-time
data, so the final „re-invocation“ is a no-op for never-changing UI components and doesn't
require memory allocation.
Second, the Windows main layout callback is only re-invoked when the callback returns
Update::RefreshDom and things like GPU transforms, animations or style modifications can be
done without requiring calling layout() again.
Third, Azul has ways to manage infinite / sparse datasets and you only need to return in the DOM what is actually on-screen, which will be a few hundred DOM nodes at most. So, for the newcomer, Azul is easy to use at first with a simple programming model, while still allowing to optimize the performance heavily - once that actually is a problem.
Fourth, Azul uses caches internally for everything, including the incremental HTML layout, so window resizing is incredibly fast.
Isn't this just a more complex way of signals-and-slots or observer patterns?
No. While both are used for communication, signals-and-slots still require manual wiring between UI objects, often leading to a complex web of connections. Azul's backreferences let you create a formal State Graph that is independent of the UI layout, making data flow clearer and preventing your application logic from being tied to your visual design.
„But my class hierarchy is my application structure. Separating them sounds like boilerplate.“
Fusing your logic to the visual hierarchy makes refactoring the UI difficult and your code untestable outside the toolkit. Decoupling them allows your core logic to be independent, portable, and easier to test.
How is this different from Redux / Context API?
Redux and Context are workarounds to the default tree-based data flow. Azul‚s backreferences are a primary, built-in architectural pattern, not an escape hatch. They allow you to directly and explicitly define your application's State Graph from the ground up, rather than having to route everything through a central store or a common ancestor.
„But manually passing references down sounds like a return to prop drilling.“
This isn't manual pointer management; it's defining the logical connections of your app. This explicitness makes complex interactions (like a node graph) far easier to reason about than tracking where context is provided or how actions are dispatched and mapped to state.
Why not just use Elms update() model?
The central update function and message (Msg) types quicly grow enormous in large applications.
Azul allows you to maintain a central data model (UI = f(data)) but provides a more direct and
decentralized way for events to trigger logic via backreferences. It avoids routing every single
interaction through one monolithic function.
„A single
updatefunction is a feature, not a bug. It makes all state changes predictable and easy to debug.“
Azul retains predictability, but the data flow is still unidirectional (Event -> State Change -> Re-render), but the „update“ logic is co-located with the relevant part of the State Graph, making the system more modular and scalable without sacrificing clarity.
SwiftUI / Compose already have @State, @Binding, and @EnvironmentObject
Those tools are still fundamentally designed around the Visual Tree. @EnvironmentObject is similar
to React's Context, and @Binding is a form of two-way data binding down the hierarchy. Azul's approach
is to formally separate the State Graph from the view hierarchy completely, which provides a cleaner
solution for non-hierarchical data dependencies that often require complex workarounds in other frameworks.
Final Conclusion
Azul is more than just a new way to build UIs. It is a path toward more robust, scalable, and maintainable software. When logic is decoupled from presentation, it becomes testable and portable, independent of the framework being used. When data flow is explicit and follows the logical structure of the application, complexity becomes manageable instead of exponential.
This way, we can finally build ambitious, graph-like applications, such as node editors or complex dashboards, with the confidence that our tools are supporting our design, not fighting it. The goal of a "perfect" toolkit is to eliminate accidental complexity so we can focus on the essential - and the first step is to simply give developers the freedom to build with their data model, instead of against it.
We hope this comparison not only helps developers understand the goal of Azul, but also to influence the general GUI ecosystem, in order to articulate the existing problems better, so that we can solve them, once and for all.