A critical review of Xilem in 2026

Source: hackmd.io
19 points by PuercoPop 23 hours ago on lobsters | 8 comments
# 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](https://raphlinus.github.io/rust/gui/2022/05/07/ui-architecture.html) 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](https://xi.zulipchat.com/#narrow/channel/354396-xilem/topic/A.20mirage.20of.20a.20future.20Xilem/with/554031736) a.k.a. [Non-contiguous app state](https://github.com/linebender/xilem/pull/1444) 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<State, ParentAction, ChildAction, Context: ViewPathTracker, V, F>( view: V, map_fn: F, ) -> MapMessage< V, State, ParentAction, ChildAction, Context, impl Fn(Arg<'_, State>, MessageResult<ChildAction>) -> MessageResult<ParentAction> + 'static, > where State: ViewArgument, ParentAction: 'static, ChildAction: 'static, V: View<State, ChildAction, Context>, 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<TimelineRequest>| { 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 with `virtual_scroll`, `fork` and `worker`, 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 a `proxy`. - 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 to `proxy`, 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 the `worker` element and `RawProxy` 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"](https://xi.zulipchat.com/#narrow/channel/354396-xilem/topic/Inference.20Hint.20Tokens/with/555335483). 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.