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. ui-disconnected

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.