A declarative WinUI 3 library built around components, typed messages, and native controls.
- 📦 crates.io
- 📖 docs.rs
- 🚀 Getting started
- 🧩 Samples
- 📁 Source
- Self-contained deployment
- Canvas integration
- Composition integration
Use Reactor when an application needs native WinUI 3 controls and you would rather describe what the UI should look like than manually keep a control tree in sync with application state. It is a good fit for forms, navigation, data-driven views, and apps that combine WinUI controls with Canvas, Composition, or WebView2.
Use windows-window instead when you need an HWND and message loop without
WinUI. Use windows-composition directly for a retained visual tree
without XAML controls.
A Reactor app is built from components. A component owns some state and has a view method that
describes the controls currently on screen. Controls send typed messages back to the component,
the component updates its state, and Reactor refreshes the parts of the native WinUI tree that
changed.
event -> message -> update state -> build a new view -> update WinUI
This is the main pattern to learn. Component fields are the normal place for state, and imperative control references are not needed for everyday UI code.
For a static window, getting text on screen can be this small:
use windows_reactor::*;
fn main() {
App::run("Hello, Windows!".into()).unwrap();
}Strings convert to a View containing a TextBlock. Create a TextBlock yourself only when its
properties need to be changed.
After adding the dependency shown in the crate README, the following is a complete counter app:
use windows_reactor::*;
#[derive(Clone)]
enum Message {
Increment,
Reset,
}
struct Counter {
count: i32,
}
impl Component for Counter {
type Input = ();
type Message = Message;
fn create(_input: &(), _context: &ComponentContext<Self>) -> Self {
Self { count: 0 }
}
fn update(&mut self, message: Message, _context: &ComponentContext<Self>) {
match message {
Message::Increment => self.count += 1,
Message::Reset => self.count = 0,
}
}
fn view(&self, _input: &(), context: &mut ViewContext<Self>) -> View {
let content = StackPanel::new().spacing(8.0).children((
format!("Count: {}", self.count),
Button::new()
.on_click(context.message(Message::Increment))
.content("Increment"),
Button::new()
.on_click(context.message(Message::Reset))
.content("Reset"),
));
context.window_frame("Counter", content)
}
}
fn main() {
App::run_component::<Counter>(()).unwrap();
}The four component methods have straightforward jobs:
| Method | Purpose |
|---|---|
create |
Make the initial component state |
update |
Handle a message and change that state |
view |
Describe the UI for the current state |
input_changed |
Optionally react when a parent changes this component's input |
Input is () because the root component has no parent-owned data. Message is an enum because
the component can receive two kinds of event. The button callbacks enqueue those messages; they do
not mutate the component directly.
App::run_component creates the WinUI application and first window, mounts Counter, and runs the
UI loop.
window_frame gives a Reactor window an integrated WinUI title bar. It creates the required
two-row layout, uses the same text for the native and visible window titles, and places the
component's content below the title bar:
context.window_visuals(WindowVisuals::new().backdrop(WindowBackdrop::Acrylic));
context.window_frame("Canvas keyboard input", content)Call window_frame from the component that supplies the window's native root so the frame fills
the window. The title-bar container is not a tab stop.
window_title and window_visuals remain available for windows that use the system title bar.
They declare native window state without adding title-bar content to the view. Material backdrops
do not extend through the system title bar, so use window_frame when Mica or Acrylic should cover
the complete window.
WinUI controls use typed builders. Start with Control::new(), set properties, connect events, and
add content or children:
Border::new()
.padding(16.0)
.content(
StackPanel::new().spacing(12.0).children((
TextBlock::new()
.text("Account")
.font_size(24.0),
TextBox::new().placeholder_text("Name"),
Button::new().content("Save"),
)),
)Use content for a control with one child, such as a button or border. Use children for a
container with an ordered set of children. Tuples are convenient because the children may have
different control types. For these ordinary content and container controls, put content or
children last in a builder chain because it finishes the control and returns a View.
Controls with several named content areas expose one builder method for each area. The method signature distinguishes a single view from a keyed collection:
NavigationView::new()
.menu_items([
("home", NavigationViewItem::new().content("Home")),
("files", NavigationViewItem::new().content("Files")),
])
.footer_menu_items([(
"settings",
NavigationViewItem::new().content("Settings"),
)])
.content("Page content")
.into()These named methods remain part of the control builder, so properties and other named areas can be chained after them. The compiler rejects passing one view to a collection area or a collection to a single-view area. Assigning the same named area more than once replaces its earlier value.
Values that implement Into<View> can be used directly with these methods. In particular, use a
&str or String for ordinary text and reach for TextBlock only to set font, layout,
accessibility, or other control properties:
StackPanel::new().children((
"A short label",
format!("Welcome, {}!", self.name),
TextBlock::new().text("Styled text").font_size(24.0),
))Layout is just more builder methods. Containers describe their rows, columns, direction, or spacing, while children say where they belong:
Grid::new()
.rows([GridLength::Auto, GridLength::Star(1.0)])
.columns([GridLength::Auto, GridLength::Star(1.0)])
.children((
TextBlock::new().text("Name").grid_row(0).grid_column(0),
TextBox::new().grid_row(0).grid_column(1),
TextBlock::new()
.text("Details go here")
.grid_row(1)
.grid_column_span(2),
))The builders expose native WinUI concepts with Rust types, so invalid property values and callback payloads are usually caught by the compiler.
For an editable control, pass the current value as a property and route changes back through a message. This is often called a controlled control:
struct Profile {
name: String,
}
impl Component for Profile {
type Input = ();
type Message = String;
fn create(_input: &(), _context: &ComponentContext<Self>) -> Self {
Self {
name: String::new(),
}
}
fn update(&mut self, name: String, _context: &ComponentContext<Self>) {
self.name = name;
}
fn view(&self, _input: &(), context: &mut ViewContext<Self>) -> View {
StackPanel::new().spacing(8.0).children((
TextBox::new()
.text(self.name.clone())
.placeholder_text("Type your name")
.on_text_changed(context.forward()),
format!("Hello, {}!", self.name),
))
}
}Use context.message(value) when an event should always send the same message. Use
context.callback(function) when the event carries a value that should become part of the
message enum. When the event payload already is the component's message type, as in the example
above, context.forward() is the shortest form.
Keep event-driven state changes in update, and keep view focused on turning the current state
into controls. That makes the direction of data flow easy to follow.
Border can act as a focusable interaction surface around Canvas, Composition, or another custom
view. Enable tab focus and native post-event pointer focus, then use routed callbacks for input
events whose WinUI Handled value must be set before the native callback returns:
Border::new()
.is_tab_stop(true)
.focus_on_pointer_release(true)
.on_preview_key_down(context.routed_callback(|info: KeyEventInfo| {
match info.key {
VirtualKey::LEFT => RoutedMessage::handled(Message::MoveLeft),
VirtualKey::RIGHT => RoutedMessage::handled(Message::MoveRight),
_ => RoutedMessage::bubble_without_message(),
}
}))
.on_character_received(context.routed_callback(|info: CharacterEventInfo| {
RoutedMessage::handled(Message::Character(info.character))
}))
.on_got_focus(context.callback(Message::Focused))
.on_lost_focus(context.callback(Message::Blurred))
.content(custom_surface)The routed callback inspects an owned payload and decides whether the native event is handled.
Its optional component message still enters the normal queue, so Component::update and
reconciliation never run inside the WinUI callback. Return bubble_without_message for keys such
as Tab that should retain their normal XAML behavior. Input bubbles when the component queue is
already full. Once handled, its deferred message retains its native FIFO position and waits for
component queue capacity.
KeyEventInfo includes the mapped and original virtual key, physical-key status, and modifier
state. CharacterEventInfo::character is one UTF-16 code unit so surrogate pairs are preserved
without lossy conversion. focus_on_pointer_release(true) queues a native
FocusState::Pointer request from the routed pointer event. The request runs after the native
callback returns. It works when a child such as Canvas is the direct hit-test source.
ElementRef<Border> supports other programmatic focus cases.
For small, stateless pieces, use an ordinary function that returns View:
fn section_heading(text: &str) -> View {
TextBlock::new()
.text(text)
.font_size(20.0)
.font_weight(FontWeight::BOLD)
.into()
}When a piece needs its own state, messages, or lifecycle, make it a component. Parents pass data
through the component's Input:
#[derive(Clone, PartialEq)]
struct GreetingInput {
name: String,
}
struct Greeting;
impl Component for Greeting {
type Input = GreetingInput;
type Message = ();
fn create(_input: &GreetingInput, _context: &ComponentContext<Self>) -> Self {
Self
}
fn view(
&self,
input: &GreetingInput,
_context: &mut ViewContext<Self>,
) -> View {
format!("Hello, {}!", input.name).into()
}
}The parent places it in its view like any other child:
View::component::<Greeting>(GreetingInput {
name: self.name.clone(),
})Input must implement Clone + PartialEq. Reactor compares it with the previous input and updates
the child when it changes. Start with explicit inputs; shared context is more useful when data must
reach many distant descendants.
There is no special syntax for conditional UI. Build a View with an ordinary if or match:
let status: View = if self.loading {
ProgressRing::new().is_active(true).into()
} else {
"Ready".into()
};
StackPanel::new().children((
status,
Button::new().content("Refresh"),
))Use View::empty() when one branch should render nothing. View::fragment groups several sibling
views without adding a native container.
Use children for a fixed group of controls. For a changing collection, map each item to a
KeyedView and use keyed_children:
let rows = self
.tasks
.iter()
.map(|task| (task.id, task.title.clone()));
StackPanel::new().spacing(4.0).keyed_children(rows)Each (key, view) tuple converts to a KeyedView, and the title string converts to its text view.
The key should identify the logical item, such as a record ID. Do not use the current list index
when items can move. Stable keys let Reactor keep the right child component state attached as items
are inserted, removed, or reordered.
For very large collections, ItemsRepeater and VirtualSource add virtualization. Start with
keyed_children; move to virtualization only when the list is large enough to need it.
Use set_timeout to send a component message after a delay:
self.timer = Some(
context
.set_timeout(Duration::from_millis(500), Message::Tick)
.unwrap(),
);The timer runs on the UI dispatcher, so its message does not need to implement Send and waiting
does not occupy a thread-pool worker. Store the returned ComponentTimer; dropping or cancelling
it prevents delivery.
Rendering, component updates, and native controls live on the UI thread. Use
spawn_background for blocking or CPU-intensive work, then return a message with the result:
enum Message {
Load,
Loaded(String),
}
fn update(&mut self, message: Message, context: &ComponentContext<Self>) {
match message {
Message::Load => {
self.loading = true;
_ = context.spawn_background(|_| {
let value = load_data();
Message::Loaded(value)
});
}
Message::Loaded(value) => {
self.value = value;
self.loading = false;
}
}
}The background closure must only capture Send data and must not touch controls or component
state. Put expected failures in the returned message, usually as Result<T, E>, and display the
result after update stores it.
Use run_window for a native operation that must run on the owning UI thread with the window's
HWND. For example, a Win32 message box can return its selection as a component message:
Message::Confirm => {
let accepted = context.run_window(|window| {
let answer = unsafe {
MessageBoxW(
window.as_raw(),
w!("Continue with this operation?"),
w!("Confirm"),
(MB_YESNO | MB_ICONQUESTION) as u32,
)
};
Message::Answered(answer)
});
if !accepted {
self.status = "Another window operation is pending".to_string();
}
}
Message::Answered(IDYES) => {
self.status = "You chose Yes".to_string();
}Reactor runs the closure in the next host dispatch after the current publication commits and
queues its returned message. It discards the work if publication fails, the requesting component
retires, or the window starts closing. A true return means that the work was staged, not that it
is guaranteed to run. The WindowHandle is scoped to the closure. Each Reactor window accepts one
pending operation at a time, so a second request returns false until the first operation runs or
is discarded.
Component dispatch for the owning window is suspended until the closure returns. A native modal
loop can continue drawing and processing its own input, and other Reactor windows remain
independent. Keep slow non-UI work in spawn_background; run_window is for native calls such as
modal dialogs that must remain on the UI thread.
The message-box sample contains the complete
component. windows-pickers builds on the same mechanism and provides request methods that map
picker results into component messages.
The component/message/view loop covers most application code. These APIs solve more specific problems:
| API | Use it for |
|---|---|
use_effect |
Starting and cleaning up an external subscription |
use_effect_guard |
Owning a subscription or revoker that cleans itself up when dropped |
Context<T> |
Sharing app-wide data such as a theme with distant descendants |
ElementRef<T> |
Focus or another operation that cannot be expressed as state |
open_window |
Opening an independent secondary window |
run_window |
Running a native modal operation with the owning HWND |
ItemsRepeater |
Virtualizing a large collection |
Prefer component input over context for normal parent-to-child data, and prefer properties and
messages over ElementRef. The declarative path is usually shorter and easier to maintain.
Reactor detects a staged Windows App Runtime or resolves an installed framework package at
startup. For self-contained deployment, use windows-reactor-setup from build.rs. Compare the
framework_dependent and
self_contained samples when choosing how to
package the app.
Use the gallery to explore individual WinUI controls. The
standalone samples under crates/samples/reactor show Reactor
concepts and behaviors:
| Example | What it shows |
|---|---|
function-component |
Child components and input |
component-input |
Controlled component input |
keyed-list-reorder |
Stable identity in changing lists |
async-state |
Background work |
message-box |
Modal native window work |
use-effect and context |
Lifecycle work and shared data |
pointer-tracking |
Pointer capture and movement |
exit-transition |
Transition-driven removal |
secondary-window |
Multiple Reactor windows |
calculator |
A larger component with derived state |
The counter demonstrates components, state, messages, and
events. The gallery is a control catalog. The
navigation and
dotsweeper samples show how these same ideas fit
together in a larger application. See the composition,
webview, and
Canvas samples only when the app needs those integrations.
The canvas-keyboard sample is a focusable custom-rendered
text surface with routed edit keys, UTF-16 character input, visible focus state, and a standard
WinUI button that demonstrates focus transfer. It shows when custom input is useful without
replacing TextBox for ordinary text editing.
The remainder of this page describes how the crate is built and maintained. Applications do not need it to use Reactor.
| Layer | Location |
|---|---|
| Public frontend | src/core/public.rs, src/element.rs, src/generated.rs |
| Component lifecycle and effects | src/core/component/mod.rs |
| Reconciler | src/core/pump |
| Scheduling | src/core/engine/mod.rs, src/core/scheduler.rs |
| Native runtime | src/native/winui |
| Recording runtime | src/test/recording/mod.rs |
| Typed integrations | src/reference.rs |
Components produce the public View representation. The Pump plans tree and lifecycle changes,
then publishes commands to a runtime. The WinUI runtime applies those commands to native objects.
RecordingRuntime consumes the same command stream for deterministic tests.
In a debug build, set WINDOWS_REACTOR_TRACE=1 to print a reconciliation summary before each
nonempty component update is applied. The trace reports native property, topology, subscription,
creation, and destruction command counts.
One structural tree handles controls, components, fragments, slots, dialogs, overlays, and windows. Keyed views retain ownership across moves. Generational window, component, and node identities reject work that targets retired objects.
Generated controls store shared payloads behind Rc; cloning a view shares the payload.
Runtime-generated identity maps use rustc_hash. Collections keyed by application data keep
randomized hashing.
Unexpected native command failures are fatal because a partially applied batch would leave the native and retained trees inconsistent. Native subtree destruction detaches the subtree's external edge without clearing internal native collections that are destroyed in the same batch. This lets WinUI finish deferred visual-state work safely.
ToolTip is an internal attachment type. The public surface is TooltipExt and Tooltip.
Reactor-owned XAML objects never cross the public API. Typed ElementRef commands and observations
are represented in both the WinUI and recording runtimes. Observations follow structural
replacement and reject late callbacks by window and node identity. Accepted one-shot requests
complete exactly once; IntegrationError::Native retains the HRESULT and Unavailable reports a
retired or unavailable target.
Swap-chain panel metrics include a binding generation so an integration can distinguish resize
from structural panel replacement. request_surface_frame schedules one normal-priority
UI-thread callback independently of CompositionTarget::Rendering; Canvas uses it to keep drawing
during interactive resize without invoking application drawing code from a layout callback.
Canvas owns its devices, swap chains, image sources, resize handling, and recovery. Composition owns application visual trees and animations. WebView users receive the CoreWebView2 object rather than Reactor's XAML control.
Routed keyboard callbacks are stored separately from ordinary event callbacks. WinUI key and
character arguments are copied into owned payloads, the callback decides Handled synchronously,
and any resulting component message is placed in the existing native-event FIFO. This preserves
native event order without running Component::update or reconciliation across a WinRT callback.
Callback replacements publish transactionally and do not replace the native subscription.
crates/tools/reactor refreshes pinned WinUI, Windows App SDK, and WebView2 metadata, resolves
crates/tools/reactor/src/winui.toml, and generates:
| Output | Contents |
|---|---|
crates/libs/reactor/src/generated.rs |
Public control builders and retained data |
crates/libs/reactor/src/native/winui/generated.rs |
Native command application |
crates/libs/reactor/src/native/winui/bindings.rs |
Minimal WinUI bindings |
crates/libs/canvas/src/reactor_bindings.rs |
Minimal Canvas bridge bindings |
crates/tests/libs/reactor_surface/src/generated_surface.rs |
Live projected API cases |
bindings.txt is the hand-maintained runtime filter. control_bindings.txt is generated from
winui.toml. Content properties come from WinUI's ContentPropertyAttribute, including inherited
properties. Feedback values come from event payloads instead of generated property readers.
Generated Rust files are committed and must not be edited by hand. After changing the schema, metadata inputs, filters, or generator, run:
cargo run -p tool-reactor --quiet
cargo run -p tool-reactor --quiet
cargo check -p windows-reactor --quiet
The second generator run must leave the tree unchanged.
Bindings used only by the test feature are allowed to be dead in a normal build. Enabling the
feature removes that allowance so the live surface build checks all generated test callables.
| Layer | Command or location |
|---|---|
| Internal deterministic tests | cargo test -p windows-reactor |
| External API tests | cargo test -p test_reactor |
| Generator tests | cargo test -p tool-reactor |
| Live handwritten fixtures | cargo run -p test-reactor-selftest -- --headless |
| Generated WinUI surface | cargo run -p test-reactor-surface -- --headless |
| Planner benchmarks | cargo run -p test-reactor-bench --release |
| Live grid benchmark | cargo run -p test-reactor-bench --bin reactor-live-grid --release |
| Live input benchmark | cargo run -p test-reactor-bench --bin reactor-live-input --release |
| Live Notepad benchmark | cargo run -p test-reactor-bench --bin reactor-live-notepad --release |
The generated surface test covers projected controls, properties, events, content, collections, slots, attachments, virtual items, and TreeView nodes. Handwritten self-tests own imperative references, retirement, and other OS interactions.
The live Notepad benchmark uses the same controlled TextBox shape as the reactor-notepad
sample. It injects Unicode keyboard input and measures raw WM_CHAR, WinUI TextChanged,
Text() retrieval, Reactor event and component queues, reconciliation, Rust allocations, and
whether controlled feedback causes a native write-back. Use --text-size to test document-size
scaling. Run the release build with its window in the foreground and leave the machine idle while
collecting results. The test-only probes add instrumentation overhead, and the injected
KEYEVENTF_UNICODE path does not measure physical-key translation or IME composition.
Pass --filter <name> to run matching handwritten fixtures. For example:
cargo run -p test-reactor-selftest -- --headless --filter Window_NestedOperationRearming
crates/libs/reactor/public-api.txt is the checked public API snapshot. Regenerate it with the
repository's pinned cargo-public-api process after an intentional API change.