A critical review of Xilem in 2026
This is a long-overdue review of Xilem's current state, with a focus on pain points and needed changes.
My opinion, which I think is widely shared by people who've worked on Xilem, is that the current architecture isn't remotely close to working.
Making non-trivial apps with Xilem is painful, people aren't using it, other frameworks are much more convenient.
Xilem needs to change or it will die.
The developer experience
Xilem's DX is, to be blunt, awful.
There's a few recurring problems:
- Xilem still hasn't found a scalable way to compose components and manage complex state.
- The architecture is very generics-heavy, in a way that can blow up compile times if the user doesn't know how to appease the trait solver. Future compiler releases might solve the problem, but for now this is a footgun.
- The generics-heavy abstractions lead to obtuse error messages. Even when the error points at a legitimate root cause, finding that cause from the error message is always way harder than it should be.
- The first-time user experience has a lot of small papercuts, such as having to write + use<> in the signature of component functions.
These problems aren't from a lack of care: Philip, Daniel and I (and others) have spent a lot of time trying to find better ways to compose the UI and make the error messages better.
Speaking from experience, our efforts have not born bruit, because of Xilem suffers from profound architectural and organizational problems that we've failed to address.
General organizational problems
Xilem has flipped between being a pure research effort, a corporate-funded project meant to showcase the potential of the Linebender stack, and a general-purpose GUI framework for everyday coding.
This pull between different goals has led to systemic problems, not because any specific maintainer's decisions, but because of general project drift. The biggest problems are:
- Premature optimization: The Xilem architecture has been driven by efforts to maximize performance. Given that we still don't benchmark Xilem, these efforts are mostly based on faith.
- Complexity addiction: Xilem is way too complex. Its essential complexity is high, and we keep solving problems by adding more generic parameters, more traits, more complexity.
- Scope creep: The framework is too large and feature-rich, given that the architecture is still being iterated on. It has a native backend and a web backend, a tokio runtime, about 30 traits (187 if we include xilem_web), the View trait has about 70 implementations (21 in xilem_core alone), etc. Having so much stuff makes it hard to iterate on architecture, which is why people who try changing it often end up going with clean-room projects.
This tendency to always add more, more generics, more complexity, more features, means that we have a very hard time experimenting with architectural changes because every change has a wide blast radius.
This is a major problem, because the architecture badly needs change.
Architecture problems
As far as I'm concerned, Xilem's architecture is stuck in multiple dead-ends.
Two-way data bindings don't work
Xilem's design aesthetic is centered around composing mutable access to a central state.
For example, let's say you have this definition for your app state:
rust
struct AppState {
posts: Posts,
users: UserList,
notifications: Notifications,
}
The "ideal" way Xilem would express this is with a component displaying posts, another displaying users, one displaying notifications, and a root component calling the other three:
rust
fn post_logic(posts: &mut Posts) -> impl View<...> {
...
}
fn users_logic(users: &mut UserList) -> impl View<...> {
...
}
fn notif_logic(notifications: &mut Notifications) -> impl View<...> {
...
}
fn app_logic(state: &mut AppState) -> impl View<...> {
v_stack(
post_logic(&mut state.posts),
users_logic(&mut state.users),
notif_logic(&mut state.notifications),
)
}
Ideally then, post_logic would not only read the list of posts, but also mutate it based on events and stuff. This is superficially similar to the notion of disjoint borrows in Rust, how you can borrow mutably borrow several fields of the same struct value as long as they're different fields.
More broadly, Xilem tries to embrace two-way data bindings, design patterns where saying "This element/component reads this value" also says "This element/component mutates this value". It's the idea that clicking a checkbox should directly update the is_checked field of some data model:
rust
fn view(app_state: &mut State) -> impl View<...> {
checkbox("Check me!", app_state.is_checked, |app_state: &mut State, checked: bool| {
app_state.is_checked = checked;
})
}
This focus on two-way bindings has a long history:
- Druid's architecture was entirely built around two-way bindings.
- Raph's original article about Xilem explicitly presents the "mutate child state to mutate parent state" pattern (a.k.a. lensing) through the Adapt node.
- Daniel's A mirage of a future xilem a.k.a. Non-contiguous app state was an attempt to give Xilem more ways to mutate parent state from children.
My take is simple: two-way bindings don't work in Rust (and barely work in other languages).
Xilem's obsession with two-way bindings means that every single view needs to carry around a State generic argument even though most need it, which leads to worse compile error messages and worse interfaces (see next section).
Also, having every component both read a slice of app state and mutate it produces awkward function signatures:
rust
fn post_logic(posts: &mut PostData) -> impl View<PostData> {
...
}
After years of working on Xilem, I am confident saying this: at least half its problems come from baking two-way bindings into its API.
Higher-order components are too complex
Xilem takes a cue from React and defines a lot of wrapper elements and higher-order components (HOCs):
- Provides
- WithContext
- Fork
- Lens
- MapMessage
- MapState
- Memoize
- Frozen
- RunOnce
(I'll call all of them higher-order components, though technically only Lens, Memoize and Frozen take a fn() -> View)
These components have signatures that look like this:
```rust
pub fn map_action(
view: V,
map_fn: F,
) -> MapMessage<
V,
State,
ParentAction,
ChildAction,
Context,
impl Fn(Arg<'_, State>, MessageResult) -> MessageResult + 'static,
where
State: ViewArgument,
ParentAction: 'static,
ChildAction: 'static,
V: View,
F: Fn(Arg<'_, State>, ChildAction) -> ParentAction + 'static,
{
MapMessage {
...
}
}
This is *not* an acceptable public API, and yet `map_action` is a public function. Code that uses HOCs looks like this:rust=
pub fn view(&mut self, mastodon: Mastodon) -> impl WidgetView<...> {
let user = self.user_id.clone();
fork(
virtual_scroll(
self.statuses.len(),
|timeline: &mut Self, idx| {
// If we're "close" to the last downloaded item.
if idx + BUFFER >= timeline.statuses.len() {
// Kick off the next request
timeline.pending_id = true;
timeline.requests.send(TimelineRequest { ... });
}
// ...
},
),
worker_raw(
move |proxy, mut recv: Receiver| {
let user = user.clone();
let mastodon = mastodon.clone();
async move {
// For every request, load the requested statuses
while let Some(next) = recv.recv().await {
let result = mastodon.get_account_statuses(...).await;
drop(proxy.message(result));
}
}
},
|timeline: &mut Self, sender| {
timeline.requests = sender;
},
|timeline: &mut Self, resp| {
match resp {
Ok(mut instance) => {
// ...
timeline.pending_id = false;
timeline.statuses.append(&mut instance.json);
}
Err(e) => {
// ...
}
}
Navigation::None
},
),
)
}
`` This isn't *quite* unreadable: if you're familiar withvirtual_scroll,forkandworker, you can guess that: - This displays a virtual list. - It creates a worker with three callbacks: - On first build (but *not* rebuild), callback 1 is spawned as a background task with the queue receiver. That task keeps polling the queue, processing work items from it, and sending the results to aproxy. - On first build, callback 2 is called with the queue sender and assigns it to local state. - Every time the background tasks sends a result toproxy, callback 3 is called with that result and pushes it to local state. - When the user scrolls around, and reveals new items, the virtual scroll takes the queue sender from local state and sends request for the data of these items. This is an extreme example, because theworkerelement andRawProxy` are hard to use even by the standards of Xilem, but it illustrates the kind of spaghetti logic you get when trying to compose Xilem components, especially when you're trying to compose both state and effects.
Views shouldn't have side-effects
Xilem includes several views with side-effects; the idea is that the first time that view is built, the side-effect occurs, and then rebuilding the view does nothing.
Views based on side-effects include:
- without_elements
- fork
- run_once
- task
- worker
I would also argue that the way WindowView works in multi-window apps is the same: we return a list of windows with options, and if the list includes windows that weren't there last frame (keyed by WindowId) we create those new windows with the provided options.
This doesn't really match how people usually create windows, and it creates a bunch of additional complications (for instance, options for window creation that do nothing on rebuild) that we could avoid by having a create_window() API.
Overall, the reactive model is at its best when you want to say "this is what I want the UI to look like, and I don't care how we get there". Side-effects (and creating new windows) don't fit that workflow.
We should have a first-class way to schedule work on a background thread instead, without having to declare it through a special view.
Architectural solution: First-class components
Here's my first proposal:
We explicitly define a non-generic Component type, wire all local states and side-effects through that type, and teach people to write Xilem apps by using that Component type everywhere.
Component identity would be based on stringly-typed keys, (e.g. status_icon_42), and the keys would be checked eagerly while creating the view tree.
Xilem code would look like this:
rust
fn child_component(x: &String) -> Component {
Component::new(|ctx: &mut CompCtx, meta| {
// ...
})
}
fn parent_component(...) -> Component {
Component::new(|ctx: &mut CompCtx, meta| {
let x = make_string();
let x2 = format!("{x}-2");
// ...
flex((
child_component(&x)
.resolve(ctx, Key::new(&x)),
child_component(&x2)
.resolve(ctx, Key::new(&x2)),
))
})
}
Some notes:
- parent_component and child_component both return Component, not impl View. There is no type parameter for app state, and no + use<> annotation. The closure is type-erased.
- Because every component must be resolved immediately, child_component is allowed to borrow a &String.
- The closure takes a &mut CompCtx, which is used for anything side-effect related, and meta, a ZST token which I'll call the "metadata token".
Local state
Components have local state, which can be accessed through CompCtx:
rust
fn my_component(...) -> Component {
Component::new(|ctx: &mut CompCtx, meta| {
let count = ctx.local_state();
flex((
button("+", ...),
button("-", ...),
label(format!("Total is {count}")),
))
})
}
Event callbacks can mutate the local state. Callbacks are passed alongside the metadata token:
rust
flex((
button("+", meta, |_e, count, _| {
*count += 1;
}),
button("-", meta, |_e, count, _| {
*count -= 1;
}),
label(format!("Total is {count}")),
))
Event callbacks can also return an event that will be surfaced by the component:
rust
fn fancy_button(...) -> Component<FancyClick> {
Component::new(|ctx: &mut CompCtx, meta| {
button("I'm fancy", meta, |e, (), _| {
FancyClick(e)
})
})
}
(I lied earlier, the Component type isn't quite non-generic.)
The metadata token is part of a pattern I call "type smuggling".
Because this type is mostly guaranteed to be the same between the component and the event handlers, all the intermediary types can be type-erased.
So instead of:
rust
pub fn button<State: 'static, Action>(
text: impl Into<ArcStr>,
callback: impl Fn(ButtonEvent, &mut State) -> Action,
// ...
) -> Button<State, Action>;
You have:
rust
pub fn button<State: 'static, Action>(
text: impl Into<ArcStr>,
meta: Metadata<State, Action>,
callback: impl Fn(ButtonEvent, &mut State) -> Action,
// ...
) -> Button;
Instead of:
rust
pub fn flex<State: 'static, Action, Seq: FlexSequence<State, Action>>(
axis: Axis,
sequence: Seq,
// ...
) -> Flex<Seq, State, Action>;
You have:
rust
pub fn flex<Seq: FlexSequence>(
axis: Axis,
sequence: Seq,
// ...
) -> Flex<Seq>;
This means that some type checking will move from compile time to runtime; I believe this is the right trade-off, because the metadata tokens guarantee that you'll never get downcast errors unless you try really hard to.
Side effects
Side-effects should happen in component callbacks, not during view rebuilding.
Component callbacks are those associated with interactive widgets, or explicitly requested by the component:
rust
fn my_component(...) -> Component {
Component::new(|ctx: &mut CompCtx, meta| {
ctx.side_effect(|(), ctx: &mut EventCtx| {
println!("Hello");
ctx.do_things();
});
// ...
})
}
Most code should avoid side effects, and instead send requests for data. The request is tied to a key, re-sent if the key changes, and canceled when the component is removed.
That request is processed by user code in a background worker, until that worker sends a reponse (or gets a cancelation token).
Thus, a component loading an image might look like this:
rust
fn user_avatar(username: &str) -> Component {
Component::new(|ctx: &mut CompCtx, meta| {
let avatar_data = ctx.request(UserAvatarRequest::new(username));
if let Ok(data) = avatar_data {
// ...
}
})
}
The background worker code would look like:
rust
fn background_loop(ctx: &WorkerCtx, ...) {
loop {
// ...
if let Some((request, id)) = ctx.poll::<UserAvatarRequest>() {
// ...
ctx.complete(id, avatar_data);
}
}
}
That last example is more speculative; I'm not exactly sure what the background worker would be; it might run on a tokio executor, for instance. The important is that it's separate from the UI code.
This will be a large overhaul of the event system:
- The MessageResult type will be removed.
- RawProxy will be replaced.
- map_action and map_result will be removed.
- Views with a NoElement parameter will be removed.
Window manipulation
Opening and closing windows will be side-effects, instead of being a part of the reactive system.
Opening a window:
rust
fn my_subwindow(...) -> Component {
// ...
}
fn main_window(...) -> Component {
Component::new(|ctx: &mut CompCtx, meta| {
// ...
ctx.side_effect(|_, ctx| {
let options = WindowOptions::new(...);
ctx.open_window(options, my_subwindow);
})
// ...
})
}
Closing a window:
rust
fn some_window(...) -> Component {
Component::new(|ctx: &mut CompCtx, meta| {
// ...
let button = button("Close window", meta, |_, _, ctx| {
// Just call
ctx.close_window();
// Or even
ctx.close_app();
})
// ...
})
}
Organizational solution: Worse is better
This is where it gets controversial.
Before we implement any of the above, we need to drastically cut Xilem's scope down.
This doesn't just mean removing dead weight code, it means removing features that are useful in specific cases, but not useful enough to justify the complexity penalty once you add them all together.
Some example of useful-but-not-enough features:
- Virtual scrolling lists: We added it to pitch Xilem's efficiency, and contributors have spent a lot of effort to make it work, but I don't think it's a priority use-case. And in the meantime, our non-virtual scroll example can't quite get smooth performance on my desktop computer. Virtual scrolling should be a post-MVP feature.
- Variable labels: Again, added as part of a corporate pitch, not that useful otherwise, even if they're very cool.
- OneOfCtx trait: This is an over-engineered approach to returning one of mutliple things. At worst, we should remove OneOf entirely and use type erasure instead, but I'm hoping we can rewrite OneOf to be simpler.
Other cuts we should make:
- Remove the Tokio dependency and let users set up their own executor in a background thread if they want to.
- Remove and/or merge lots of xilem examples (we have like six different "counter with two buttons" examples).
- Remove Environment.
- Remove Count enum.
- Remove DocsView, DocsViewSequence and the related code.
In general, we should be more willing to write code that's slightly less performant or elegant if it means that it's also less complex.
xilem_core in particular suffers a lot from complexity addiction because it's trying to remove all possible code duplication between xilem_masonry and xilem_web, even in cases where the duplicated code would have fewer lines that the abstraction xilem_core ends up with.
Conclusion
Uh.
Yeah. This is a lot of work.
To be clear, I don't want this article to be interpreted as "and I'll start doing all those things right now".
Mostly, I'm writing this as a roadmap for the next time I'll have time to work on Xilem, and to get everyone on the same page.
If Xilem is to survive, it will need large, sweeping changes, even if that means throwing out a lot of code and starting over.