State-Driven Subscriptions in iced 0.13
In this post, we will explore how we can use subscriptions in iced
0.13 to go from static examples to dynamic, stateful background tasks to build a Serial Port GUI for the Arduino Nano.
iced
0.13 makes managing background work in GUI applications more predictable with its state-driven Subscription system. This is especially useful when working with IO tasks that must start and stop based on app state. This post shows how to build a controller GUI for an Arduino Nano using the serial port, with a subscription that only runs when connected — and shuts down cleanly when disconnected.
We’ll cover:
-
Setting up the connection state
-
Conditionally spawning a serial event subscription
-
Creating a custom Recipe for serial I/O
-
Tying everything into update() and subscription()
The Goal
We want a GUI that:
-
Scans available serial devices and connects to one (user decision)
-
Starts reading serial data only when connected
-
Stops background tasks when disconnected
-
Streams serial events as messages into the UI
Step 1: Model Application State
Define the connection state clearly so the subscription logic can use it.
#[derive(PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum ConnectionState {
Disconnected,
Connected,
}
The GUI state stores this in SerialCommanderGui
:
nano_connection_state: ConnectionState,
/// wrapper to the Arduino Nano over a serial port
nano: Arc<Nano>,
Step 2: Add a Conditional Subscription Function
In iced
0.13, Application::subscription
is expected to return the subscriptions for the current state. This function is re-evaluated after state change, so we can conditionally start or stop subscriptions:
fn subscription(state: &SerialCommanderGui) -> iced::Subscription<Message> {
use iced::advanced::subscription::from_recipe;
let nano = Arc::clone(&state.nano);
match state.nano_connection_state {
ConnectionState::Disconnected => {
// nothing to do in the background
Subscription::none()
}
ConnectionState::Connected => {
Subscription::batch([
if state.is_playing {
// in case we are playing music, we send the `Message::StartWithTime` every 250ms
time::every(Duration::from_millis(250)).map(|_| Message::StartWithTime)
} else {
// in case we are not playing, we send the `Message::ScheduledPing` every once in a while
time::every(PING_RENEVAL_INTERVAL).map(|_| Message::ScheduledPing)
},
// in any case we collect `SerialEvents` and turn them into Messages for the UI
from_recipe(SerialSubscription { nano }).map(Message::SerialEvent),
])
}
}
}
Here, SerialSubscription is a custom Recipe that creates the serial input stream.
## Step 3: Build the SerialSubscription Recipe
Define a Recipe that streams serial events to the UI. This uses a background thread to poll the serial port and forward messages.
```rust
pub struct SerialSubscription {
pub nano: Arc<Nano>,
}
impl Recipe for SerialSubscription {
type Output = SerialEvent;
fn stream(
self: Box<Self>,
_input: EventStream,
) -> iced::futures::stream::BoxStream<'static, Self::Output> {
let nano = Arc::clone(&self.nano);
subscribe_to_serial(nano).boxed()
}
fn hash(&self, state: &mut iced::advanced::subscription::Hasher) {
state.write(b"SerialSubscription");
}
}
The actual reading loop runs in a thread and emits messages using iced::stream::channel
, as a Stream
of SerialEvent
.
pub fn subscribe_to_serial(nano: Arc<Nano>) -> impl Stream<Item = SerialEvent> {
stream::channel(1, move |mut output| async move {
std::thread::spawn(move || {
let mut retries_left = 5;
loop {
match nano.read_response() {
Ok(events) if !events.is_empty() => {
retries_left = 5;
for event in events {
let _ = executor::block_on(output.send(event.clone()));
if matches!(event, SerialEvent::Disconnected(_)) {
return;
}
}
let _ = executor::block_on(output.flush());
}
Ok(_) => {
std::thread::sleep(std::time::Duration::from_millis(500));
}
Err(err) => {
if matches!(err, SerialError::PortNotOpen) {
// implements a retry on error 5 times then return
retries_left -= 1;
if retries_left == 0 {
let _ = executor::block_on(
output.send(SerialEvent::Disconnected(nano.device.clone())),
);
return;
}
}
return;
}
}
}
});
})
}
## Step 4: Handle Serial Events in `update()`
Serial events are mapped into application messages and dispatched to the update function. Here’s a simple case:
```SerialSubscription
Message::SerialEvent(SerialEvent::DataReceived(data)) => {
// simply adds the 2nd string argument to the state, that is rendered to a text box
add_to_log(state, format!("Data: {}", data));
}
Errors and state transitions are also handled here, for example:
Message::SerialEvent(SerialEvent::Disconnected(device)) => {
state.nano_connection_state = ConnectionState::Disconnected;
add_to_log(state, format!("Disconnected from {}", device));
}
Step 5: Let iced Manage Lifecycle
The important part: you don’t manage threads or join handles directly. As soon as the app state changes, iced cancels the old subscription and starts a new one — if needed. This avoids concurrency issues and ensures the serial task is tightly bound to UI state.
Summary
Using iced
0.13 with Subscription
and Recipe
, we built a reactive serial GUI that:
-
Starts and stops I/O based on connection state
-
Streams serial messages to the UI
-
Handles errors and disconnection gracefully
-
Uses clean async boundaries without race conditions
This model is easy to extend for additional background tasks or multiple serial ports.