Skip to content

fetch

use_future runs the request; the nearest <Suspense> draws the spinner.

Two components. App owns which city is picked and an attempt counter; Weather calls use_future with both as deps, so picking another city is a new request and refresh asks for the same one again by bumping the counter. The request is built in place, where the coordinates are still borrowable, and the future that is returned owns it and is 'static. The answer is a real one: three days of weather from Open-Meteo, which needs no key, and serde reads out of the body only the fields the screen shows.

Weather never draws a pending state. It ends with let Poll::Ready(response) = response else { return; }; and leaves the waiting to the <Suspense> above it, which counts the pending futures below it and draws its fallback instead. That is the Rust answer to React's throw, and it means a leaf that needs a value can simply stop.

The three ways a request can fail — it never arrived, the server said no, the body was not the JSON expected — end up as one line on screen, which is what parse is for. The same code runs in both places: ehttp uses ureq on a thread of its own natively and the browser's fetch API on the web, and none of that difference reaches the types you write. The embed above is the wasm build, so what it fetches is subject to the browser's usual cross-origin rules. Async has the longer story, stale results included.

Run it yourself ​

sh
cargo run -p fetch
trunk serve --config examples/fetch/Trunk.toml

The source is examples/fetch/src/lib.rs.

rust
use egui_reactor::prelude::*;
use egui_reactor_elements::prelude::*;
use serde::Deserialize;

/// The cities the buttons offer, with the coordinates the API wants.
pub const CITIES: [(&str, f32, f32); 4] = [
    ("Tokyo", 35.68, 139.69),
    ("Paris", 48.85, 2.35),
    ("Reykjavík", 64.15, -21.94),
    ("Sydney", -33.87, 151.21),
];

/// What the app asks Open-Meteo for: the weather now, and three days of it.
pub fn url(latitude: f32, longitude: f32) -> String {
    format!(
        "https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}\
         &current=temperature_2m,wind_speed_10m,weather_code\
         &daily=weather_code,temperature_2m_max,temperature_2m_min&forecast_days=3\
         &timezone=auto"
    )
}

/// The answer, as much of it as the screen shows.
///
/// `serde` reads the fields it is asked for and ignores the rest, so this is
/// the shape of the screen rather than the shape of the API.
#[derive(Clone, Debug, Deserialize)]
pub struct Forecast {
    pub timezone: String,
    pub current: Current,
    pub daily: Daily,
}

#[derive(Clone, Debug, Deserialize)]
pub struct Current {
    pub temperature_2m: f32,
    pub wind_speed_10m: f32,
    pub weather_code: u8,
}

#[derive(Clone, Debug, Deserialize)]
pub struct Daily {
    pub time: Vec<String>,
    pub weather_code: Vec<u8>,
    pub temperature_2m_max: Vec<f32>,
    pub temperature_2m_min: Vec<f32>,
}

/// WMO weather code 0..99, in words. The codes the API can answer with are
/// grouped the way a forecast reads them out.
pub fn sky(code: u8) -> &'static str {
    match code {
        0 => "clear",
        1..=2 => "mostly clear",
        3 => "overcast",
        45..=48 => "fog",
        51..=57 => "drizzle",
        61..=67 => "rain",
        71..=77 => "snow",
        80..=82 => "showers",
        85..=86 => "snow showers",
        95..=99 => "thunderstorm",
        _ => "weather",
    }
}

#[component]
pub fn App(cx: &mut Cx) {
    let mut city = use_state(cx, || 0usize);
    let mut attempt = use_state(cx, || 0u32);
    let picked = *city;
    let (_, latitude, longitude) = CITIES[picked];

    rsx! {
        <View direction="column" gap={8} p={12} grow={1.0} w="100%">
            // `wrap` on the row, so a narrow window puts the buttons on two
            // lines instead of running them off the right edge.
            <View direction="row" wrap gap={8} align="center" w="100%">
                for (i, (name, _, _)) in CITIES.iter().enumerate() {
                    <City key={name} name={name} picked={i == picked} on_click={|| *city = i}/>
                }
                // `attempt` is part of the deps below, so bumping it asks for
                // the same city again.
                <Button on_click={|| *attempt += 1}>"refresh"</Button>
            </View>

            // What is being asked for. `wrap` needs a width to wrap inside,
            // which is what `w="100%"` gives it.
            <Text w="100%" wrap size={11.0}>{url(latitude, longitude)}</Text>

            <Suspense fallback={view(|cx| { cx.ui().spinner(); })}>
                <Weather city={picked} attempt={*attempt}/>
            </Suspense>
        </View>
    }
}

/// One city button: on when it is the one being shown.
///
/// `egui-reactor-elements` has no toggle, so this is the escape hatch one leaf
/// deep — the same shape every component in these examples has: take a
/// `style`, draw one thing, report the click.
#[component]
fn City(
    cx: &mut Cx,
    #[prop(default)] style: ItemStyle,
    name: &str,
    picked: bool,
    #[event] on_click: (),
) {
    let clicked = cx.leaf(&style.shrink(0.0), |ui| {
        // A hand-written leaf sets the wrap mode itself: measured in a
        // zero-width `Ui` on its first draw, a wrapping widget would report
        // one character wide and stay that way.
        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
        ui.selectable_label(picked, name).clicked()
    });
    if clicked {
        on_click.emit(());
    }
}

/// Waits for one request. It never draws a pending state of its own: the
/// `let`-`else` hands that job to the `<Suspense>` above it.
#[component]
fn Weather(cx: &mut Cx, city: usize, attempt: u32) {
    let (name, latitude, longitude) = CITIES[city.min(CITIES.len() - 1)];
    let response = use_future(cx, (city, attempt), || {
        // Built here, where the coordinates are still borrowable; the future
        // itself owns the request and is `'static`.
        let request = ehttp::Request::get(url(latitude, longitude));
        async move { ehttp::fetch_async(request).await }
    });
    let Poll::Ready(response) = response else {
        return;
    };

    rsx! {
        <View direction="column" gap={8} w="100%" grow={1.0} min_h={0.0}>
            match parse(response) {
                Ok(forecast) => {
                    <Text size={22.0} strong>
                        {format!("{name}  {:.0}°C", forecast.current.temperature_2m)}
                    </Text>
                    <Text>
                        {format!(
                            "{}, wind {:.0} km/h, local time {}",
                            sky(forecast.current.weather_code),
                            forecast.current.wind_speed_10m,
                            forecast.timezone,
                        )}
                    </Text>
                    <Separator/>
                    <ScrollArea grow={1.0} h={0.0}>
                        <View direction="column" gap={4} w="100%">
                            for (i, day) in forecast.daily.time.iter().enumerate() {
                                <View key={day} direction="row" gap={8} w="100%">
                                    <Text w={100.0}>{day.as_str()}</Text>
                                    <Text w={110.0}>{sky(forecast.daily.weather_code[i])}</Text>
                                    <Text>
                                        {format!(
                                            "{:.0}° / {:.0}°",
                                            forecast.daily.temperature_2m_max[i],
                                            forecast.daily.temperature_2m_min[i],
                                        )}
                                    </Text>
                                </View>
                            }
                        </View>
                    </ScrollArea>
                }
                Err(err) => { <Text w="100%" wrap>{format!("{name}: {err}")}</Text> }
            }
        </View>
    }
}

/// The response as a [`Forecast`], or why it is not one.
///
/// Three ways to fail, and they are all the same to the screen: the request
/// never arrived, the server said no, or the body was not the JSON expected.
pub fn parse(response: &Result<ehttp::Response, String>) -> Result<Forecast, String> {
    let response = response.as_ref().map_err(String::clone)?;
    if !response.ok {
        return Err(format!("{} {}", response.status, response.status_text));
    }
    let body = response.text().ok_or("the body is not text")?;
    serde_json::from_str(body).map_err(|err| err.to_string())
}

Released under the MIT or Apache-2.0 license.