Radiant0.3.0-rc.2

Weather App

A RadiantElement host that fetches live weather data from Open-Meteo, shares it through context, and lets a child summary card subscribe to a single slice.

Weather

The host fetches each city from Open-Meteo on connect and on click. The summary rerenders from the visible report stored in context.

Pick a city to load the current forecast.

Waiting for Venice to resolve...

The example covers:

  • createResource(this, {...}) for abortable, source-driven async fetches
  • @provideContext on the host for shared state ownership
  • @contextSelector with select on the summary card for read-only slice subscriptions
  • @contextSelector without select on the host for full-context access in render()
  • stale-while-revalidate rendering via staleTime and pendingDelay

createResource(...) is available from @ecopages/radiant. Prefer importing it from the same entry as RadiantElement / @state so your bundler keeps a single @ecopages/signals instance. The focused subpath @ecopages/radiant/signals/host-resource remains available for tree-shaking, but it must resolve to the same signals copy as the rest of Radiant.

Architecture

The host owns the active city id as @state, the provider, and the resource lifecycle. Because source reads ctx.host.activeCityId, city changes automatically abort older requests and start a new fetch — no manual refetch() needed. Successful responses update the shared context via this.provider.setContext(...).

The summary card only cares about the visible report. It subscribes to that slice with @contextSelector({ select: ... }) and reads this.visibleReport directly in render(). It does not need @consumeContext because it never calls setContext(...).

Utility code (city list, fetch helper, types) lives in weather-app.utils.ts and weather-app.types.ts. The example below focuses on the Radiant-specific part.

Summary Card

import {
	RadiantElement,
	contextSelector,
	customElement,
} from '@ecopages/radiant';
import type { WeatherReport } from './weather-app.types';
 
// weatherContext created via createContext<WeatherContext>(Symbol('weather-context'))
 
@customElement('radiant-weather-summary')
export class RadiantWeatherSummary extends RadiantElement {
	@contextSelector({
		context: weatherContext,
		select: ({ visibleReport }) => visibleReport,
	})
	visibleReport: WeatherReport | undefined;
 
	override render() {
		if (!this.visibleReport) return null;
 
		return (
			<article data={{ city: this.visibleReport.city }}>
				<p>{this.visibleReport.city}</p>
				<p>{this.visibleReport.condition}</p>
				<p>
					{this.visibleReport.temperature}
					<span>°C</span>
				</p>
				<p>{this.visibleReport.summary}</p>
				<dl>
					<div>
						<dt>Humidity</dt>
						<dd>{this.visibleReport.humidity}%</dd>
					</div>
					<div>
						<dt>Wind</dt>
						<dd>{this.visibleReport.windKph} km/h</dd>
					</div>
				</dl>
			</article>
		);
	}
}

@contextSelector on the field keeps the value in sync and schedules requestUpdate() automatically when the selected slice changes on a render-owning host.

Host

import {
	type ContextProvider,
	RadiantElement,
	contextSelector,
	createContext,
	createResource,
	customElement,
	provideContext,
	state,
} from '@ecopages/radiant';
import type { WeatherCity, WeatherContext, WeatherReport } from './weather-app.types';
import {
	DEFAULT_CITY_ID,
	WEATHER_CITIES,
	fetchWeatherReport,
	getWeatherCity,
	upsertWeatherReport,
} from './weather-app.utils';
 
const weatherContext = createContext<WeatherContext>(Symbol('weather-context'));
 
@customElement('radiant-weather-app')
export class RadiantWeatherAppElement extends RadiantElement {
	@state activeCityId = DEFAULT_CITY_ID;
 
	@provideContext<typeof weatherContext>({
		context: weatherContext,
		initialValue: {
			activeCityId: DEFAULT_CITY_ID,
			reports: [],
			visibleReport: undefined,
		},
	})
	provider!: ContextProvider<typeof weatherContext>;
 
	private weatherQuery = createResource(this, {
		pendingDelay: 500,
		staleTime: 1 * 60 * 1000,
		source: (ctx) => ctx.host.activeCityId,
		fetcher: (cityId, ctx) => fetchWeatherReport(getWeatherCity(cityId), ctx.signal),
		onSuccess: (report, ctx) => {
			ctx.host.provider.setContext({
				activeCityId: report.cityId,
				reports: upsertWeatherReport(ctx.host.provider.getContext().reports, report),
				visibleReport: report,
			});
		},
	});
 
	@contextSelector({ context: weatherContext })
	weatherState: WeatherContext = {
		activeCityId: DEFAULT_CITY_ID,
		reports: [],
		visibleReport: undefined,
	};
 
	private readonly handleCityClick = (event: Event) => {
		const button = event.currentTarget as HTMLButtonElement | null;
		const cityId = button?.dataset.cityId;
		if (!cityId) return;
		this.activeCityId = cityId;
		this.provider.setContext({ activeCityId: cityId });
	};
 
	override render() {
		const status = this.weatherQuery.status.get();
		const error = this.weatherQuery.error.get();
		const { activeCityId, reports, visibleReport } = this.weatherState;
		const activeCity = getWeatherCity(activeCityId);
		const activeReport = reports.find((report) => report.cityId === activeCityId);
		const isPending = status === 'pending';
 
		return (
			<section>
				<div>
					{WEATHER_CITIES.map((city) => (
						<button
							type="button"
							data={{ cityId: city.id }}
							on:click={this.handleCityClick}
							aria={{ pressed: city.id === activeCityId }}
						>
							{city.label}
						</button>
					))}
				</div>
				<div>
					{visibleReport ? (
						<radiant-weather-summary />
					) : (
						<div>Waiting for {activeCity.label} to resolve...</div>
					)}
				</div>
			</section>
		);
	}
}

The host uses @contextSelector without select so it receives the full WeatherContext. This is necessary here because render() reads activeCityId, reports, and visibleReport together, and some of that logic depends on instance state (weatherQuery.status.get()).

Key Patterns

  • Read-only consumers use @contextSelector alone. The summary card does not need @consumeContext because it never writes to context.
  • The host uses both @provideContext and @contextSelector. It writes via provider.setContext(...) and reads via the weatherState field.
  • createResource manages the async lifecycle. Source changes abort previous requests, staleTime caches responses, and pendingDelay avoids flash-of-loading for fast responses. No manual cleanup is needed.
  • @contextSelector on a field schedules requestUpdate() automatically on render-owning hosts. No empty method body and no manual this.update() call.

See Also