State system
The state system is the behavioral backbone of a Fission app. It separates durable product state, local widget memory, build-scope context, and reducer-side effects so the same app model can run through different shells without hidden platform coupling.
Core pieces
| | |
|---|
| Runtime-owned durable app state | Holds product truth shared across screens, reducers, tests, and shells |
| Handle to retained local widget state | Stores UI-local memory for one stable widget identity |
| Build-scope wiring handle | Binds actions, local reducers, resources, portals, media, and animations |
| | Reads GlobalState, Env, runtime values, layout snapshots, and selectors |
| Typed build-stack value scoped to descendants | Lets parents inject contextual read-only values without prop drilling |
| Typed, serializable intent with stable ActionId | Describes what happened in app terms |
| Function that updates state for an action | Centralizes mutations in one explicit path |
| Reducer-time effect and input context | Lets reducers request outside work and read returned payloads |
| Read-only derived view computation | Keeps component conversion focused on view-ready values |
GlobalState
GlobalState is implemented by the root product state managed by the runtime.
A GlobalState type must be 'static, downcastable, Send, Sync, and Debug. Common primitive types and () implement it already. App structs usually derive or implement it directly.
#[derive(Debug, Default, Clone, FissionGlobalState)]
pub struct ShopState {
pub cart: CartState,
pub session: SessionState,
}
#[derive(Debug, Default, Clone, FissionStateView)]
pub struct CartState {
pub items: Vec<CartItem>,
}
Use GlobalState for durable product facts: current route, loaded records, form drafts that should survive navigation, selected account, save status, user preferences, and feature state shared by distant widgets.
Do not use GlobalState for runtime-owned details such as current animation interpolation values or host handle stores. Do not use it for tiny UI-local state that disappears with a component unless that state is product-significant.
Reading state during component conversion
Component conversion reads app inputs with fission::build::current::<S>():
let (ctx, view) = fission::build::current::<ShopState>();
S is the concrete GlobalState type the component expects. If a component does not read app state and only needs state-independent local wiring, it may request the common scope with ():
let (ctx, _) = fission::build::current::<()>();
build::current::<S>() is valid only inside an active build pass. Calling it outside component conversion panics with a build-scope error.
ViewHandle<S>
ViewHandle<S> is the read-side handle for component conversion.
| |
|---|
| Returns the current &S app state |
| Returns the generated typed state view when S: FissionViewField |
| Runs a one-off closure selector against &S |
| Runs a reusable Selector<S> implementation |
| Reads the current environment |
| |
| |
| Reads the current viewport size |
| Reads the optional previous layout snapshot |
| Reads a previous layout rectangle for a widget id |
| Reads previous constraints for a widget id |
animation_value(id, property) | Reads runtime animation state for a widget property |
| Reads runtime video state for a widget |
ViewHandle is not a long-lived reference. Store derived values, not the handle itself, if data must travel into an action or effect payload.
BuildCtxHandle<S>
BuildCtxHandle<S> is the write-side wiring handle for component conversion. It records relationships the runtime will use later.
| |
|---|
| Creates an ActionEnvelope for a normal app-state reducer |
register::<A, H>(handler) | Registers a reducer handler without immediately binding a widget event |
bind_local(action, field, handler) | Binds a reducer to a retained local StateField<T> |
Declarative bind(...) and bind_local(...) install one handler per effective
action ID in each build while preserving the payload of every returned action
envelope. Repeated widgets should carry their differing stable context in the
action value. register(...) is different: it is explicitly multicast, so all
handlers registered for that action type run on dispatch.
| request_animation_for(id, request) | Queues an animation request for a widget id |
| anim_for(id) | Creates a scoped animation helper for a target widget id |
| register_video(...) | Registers a video node with the runtime |
| register_web_view(...) | Registers a web view node with the runtime |
| register_portal(...) | Registers a default-layer portal |
| register_portal_with_id(...) | Registers a portal with an explicit widget id |
| register_portal_with_layer(...) | Registers a portal in a specific layer |
| with_resources(...) | Gives controlled access to the build resource registry |
| video_controls(id) | Creates controls for a registered video widget |
BuildCtxHandle does not mutate app state directly. App state changes happen in reducers. The handle records that a future event should dispatch an action, update local state, start an animation, register a resource, or connect another runtime-managed behavior.
Handle lifetime
BuildCtxHandle and ViewHandle are copyable handles, not raw references. They resolve through the active build scope. This lets Fission expose ergonomic let (ctx, view) = build::current::<State>(); syntax without handing authoring code a reference that can outlive the shell-owned build data.
Correct usage:
impl From<CartBadge> for Widget {
fn from(_: CartBadge) -> Widget {
let (_, view) = fission::build::current::<ShopState>();
Text::new(format!("{}", view.state().cart.items.len())).into()
}
}
Incorrect usage:
// Do not store handles for later reducer, async, service, or static use.
struct BadCache<S: GlobalState> {
view: ViewHandle<S>,
}
If a handle is used outside an active build pass, Fission panics with a clear scope error. That is part of the contract; it prevents stale build handles from becoming hidden global state.
Local widget state is retained state associated with a component field and widget identity.
Declare it with #[fission_component] and #[local_state(default = ...)]:
#[fission_component]
pub struct Counter {
#[local_state(default = 0)]
count: i32,
}
The macro removes local-state fields from the component's public prop fields and generates accessor methods. The accessor returns StateField<T>:
let count = counter.count();
let value = count.get();
Bind a local reducer with BuildCtxHandle::bind_local:
#[fission_reducer(Increment)]
fn increment(count: &mut i32) {
*count += 1;
}
let (ctx, _) = fission::build::current::<()>();
let count = counter.count();
let on_press = ctx.bind_local(Increment, count.clone(), reduce!(increment));
Local state persists across rebuilds while the same widget identity is active. It is pruned after a build when that identity is no longer present. It is not persisted across app restarts unless the app explicitly mirrors it into durable state.
StateField<T>
StateField<T> is the handle returned by local-state accessors.
| |
|---|
| Clones and returns the current retained value |
| Returns the component identity string used by the generated accessor |
| |
| Returns the LocalStateKey |
| Builds the scoped local action id for action A |
StateField<T> is intentionally a handle. Updating it happens through ctx.bind_local(...) and a reducer, not by mutating the field directly inside component conversion.
Fission can distinguish simple static siblings by structural position. Dynamic children that can be inserted, removed, filtered, or reordered should use explicit widget ids.
let children = rows
.into_iter()
.map(|row| RowCard { row: row.clone() }
.id(WidgetId::explicit(&format!("row.{}", row.id))))
.collect();
Use a durable key from the data. Do not use the list index for reorderable data. An unstable id makes retained local state appear to move between rows.
Provider stack
Provider<T, F> installs a typed value for descendants during component conversion.
#[derive(Clone)]
struct SectionName(&'static str);
Provider::new(SectionName("billing"), || BillingFields).into()
A descendant reads the nearest active value of that type:
let section = fission::build::read::<SectionName>();
Use try_read::<T>() when the value is optional:
let section = fission::build::try_read::<SectionName>()
.unwrap_or(SectionName("default"));
Provider values must be Clone + Send + Sync + 'static. Reads clone the value. This avoids borrowed stack data escaping the build pass.
Providers are nested. The nearest provider of the requested type wins; when a nested provider finishes converting its child, the parent value becomes visible again.
Providers are not durable state. Use them for read-only contextual values scoped to a subtree. Use GlobalState or local widget state for values that need reducers or retention.
Action
An Action is the typed message that describes user intent or a runtime result.
The trait requires values to be serializable and to provide a stable ActionId through static_id(). In ordinary app code, prefer #[fission_reducer(ActionName)] when one reducer owns one action. Use #[fission_action] for manual action structs that are shared, public, or need separately documented fields.
Good action names describe intent in product language: SaveRequested, FilterChanged, CardAddedToCart, SessionExpired, or PhotoCaptured.
Avoid vague actions such as Clicked or Update unless the surrounding type makes the intent unambiguous.
Reducers
A reducer receives current state, an action, and optionally ReducerContext, then updates state.
Typical shapes:
#[fission_reducer(SetFilter)]
fn on_set_filter(state: &mut ShopState, filter: ProductFilter) {
state.filter = filter;
}
#[fission_reducer(SaveRequested)]
fn on_save_requested(state: &mut ShopState, ctx: &mut ReducerContext<ShopState>) {
state.saving = true;
// request outside work through ctx.effects
}
Reducers are the normal place for state mutation. They should be deterministic except for explicitly requested effects. Do not put host work, network calls, file writes, or hidden global mutation directly inside a reducer body.
ReducerContext<S>
ReducerContext<S> is available only while a reducer is running.
| |
|---|
| Queues jobs, services, commands, capabilities, timers, and other runtime-managed work |
| Reads explicit input that arrived with this dispatch, such as job results or capability payloads |
ReducerContext is not storage. It should not be retained after the reducer returns.
Selectors and generated views
Generated state views give component conversion a typed path through state:
let (_, view) = fission::build::current::<ShopState>();
let cart_count = view.global().cart().items().map(|items| items.len()).get();
Use #[derive(FissionStateView)] on nested state structs you want to traverse this way. Scalar and collection fields return ValueView<T>, which borrows the source field and clones only when get() is called.
For one-off reads, use view.select(...):
let cart_count = view.select(|state| state.cart.items.len());
For reusable derived view models, implement Selector<S> and call view.select_with::<YourSelector>().
Selectors are read-only. Do not use them to mutate state or hide side effects.
Decision table
| |
|---|
| |
One widget's temporary retained memory | #[local_state] and StateField<T> |
Parent-scoped contextual read-only data | Provider<T, F>, read::<T>(), try_read::<T>() |
Theme, locale, viewport, window, safe-area, and platform presentation context | |
Bind a widget event to app-state change | BuildCtxHandle::bind(...) or with_reducer!(...) |
Bind a widget event to local state change | BuildCtxHandle::bind_local(...) |
Read app state or derived view models | ViewHandle::state, global, select, or select_with |
| |
Keep identity stable across reordered dynamic children | .id(WidgetId::explicit(...)) |
Related pages