Run typed host work
Sooner or later, every real app needs something that the operating system, browser, or device must do on its behalf. Opening a native file picker, opening an external web address, or handing a product-specific flow to the host are common examples.
In Fission, this is called host work.
Host work means work that belongs on the shell side of the app boundary rather than inside your deterministic core app logic. Your reducer can decide that a file picker should open, but the reducer is not the file picker. The shell host performs that operation and sends the typed result back.
This recipe walks through a practical case: letting the user import a text file. Along the way, it also answers an important design question: when should you use a capability, when should you use a job, and when should you use a resource?
The problem we are solving
Imagine a notes app with an Import file button.
When the user presses it, three different kinds of work may happen.
First, the app needs the host to show a native picker so the user can choose a file. That is host work.
Second, once the bytes come back, the app may want to parse the file contents or transform them into domain data. That is ordinary app-owned async work.
Third, if the import screen later needs something that should stay alive while the screen is mounted, such as a polling timer or a background watch session, that becomes resource-owned work.
Fission keeps those concerns separate on purpose.
Step 1: keep the screen state explicit
Start by storing only the product-level data the screen needs to show.
use fission::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct ImportState {
pub importing: bool,
pub imported_text: Option<String>,
pub error_message: Option<String>,
}
impl AppState for ImportState {}
This state does not try to store platform picker internals. It stores what your product cares about: whether an import is in progress, the imported text if it succeeded, and an error message if it failed.
Step 2: define the actions in app language
A good action list tells the story of the feature.
#[fission_action]
pub struct ImportPressed;
#[fission_action]
pub struct FilesPicked;
#[fission_action]
pub struct FilePickFailed;
#[fission_action]
pub struct ImportParsed;
#[fission_action]
pub struct ImportParseFailed;
The important habit is that the actions describe product events, not widget implementation details. That keeps the reducer easy to read later.
Step 3: use a capability for the host-owned step
When the user presses Import, the first thing you need is a native picker. That belongs to the host, so the reducer requests a capability.
fn on_import_pressed(
state: &mut ImportState,
_action: ImportPressed,
ctx: &mut ReducerContext<ImportState>,
) {
state.importing = true;
state.error_message = None;
ctx.effects
.capability(
PICK_OPEN_FILES,
PickOpenFilesRequest {
allow_multiple: false,
mime_types: vec!["text/plain".into()],
extensions: vec!["txt".into(), "md".into()],
},
)
.on_ok(ctx.effects.bind(
FilesPicked,
reduce_with!(on_files_picked),
))
.on_err(ctx.effects.bind(
FilePickFailed,
reduce_with!(on_file_pick_failed),
));
}
This is the defining capability pattern in Fission.
The reducer does not open the file picker directly. Instead, it records a typed request through ctx.effects.capability(...). After the reducer returns, the shell host performs the work. When the host finishes, the runtime dispatches the success or failure action you bound with on_ok(...) or on_err(...).
This is why reducers stay deterministic even when the product needs real platform integration.
Step 4: read the capability result and hand off app-owned work
When the picker completes, the reducer receives the callback action together with typed input in ctx.input.
struct ParseImportedText;
fn on_files_picked(
state: &mut ImportState,
_action: FilesPicked,
ctx: &mut ReducerContext<ImportState>,
) {
let Some(files) = ctx.input.capability_ok(PICK_OPEN_FILES) else {
state.importing = false;
state.error_message = Some("No file was returned by the picker.".into());
return;
};
let Some(file) = files.into_iter().next() else {
state.importing = false;
state.error_message = Some("You did not choose a file.".into());
return;
};
ctx.effects
.app(PARSE_IMPORTED_TEXT_JOB, file.bytes)
.on_ok(ctx.effects.bind(
ImportParsed,
reduce_with!(on_import_parsed),
))
.on_err(ctx.effects.bind(
ImportParseFailed,
reduce_with!(on_import_parse_failed),
));
}
This step is where the distinction between capability and job becomes useful.
The file picker was host-owned, so it was a capability.
Parsing the returned bytes is ordinary app work, so it becomes a job. That could mean decoding text, validating a format, or transforming the file into your domain model.
Why not keep using a capability here? Because the host is no longer the right owner. Once the bytes are in your app, the work belongs to your application logic.
Step 5: finish the reducer loop cleanly
When the parse job succeeds or fails, update state in one obvious place.
fn on_import_parsed(
state: &mut ImportState,
_action: ImportParsed,
ctx: &mut ReducerContext<ImportState>,
) {
if let Some(text) = ctx.input.job_ok(PARSE_IMPORTED_TEXT_JOB) {
state.imported_text = Some(text);
state.importing = false;
}
}
fn on_import_parse_failed(
state: &mut ImportState,
_action: ImportParseFailed,
ctx: &mut ReducerContext<ImportState>,
) {
state.importing = false;
state.error_message = ctx
.input
.job_error_message(PARSE_IMPORTED_TEXT_JOB)
.map(str::to_string)
.or_else(|| Some("The selected file could not be imported.".into()));
}
These reducers are plain state updates again. That is the architectural payoff. Even though the feature crosses the platform boundary and performs asynchronous work, the state changes still happen in normal reducer code.
Step 6: render the user interface like an ordinary screen
The widget does not need special platform logic. It only binds actions and renders from state.
impl Widget<ImportState> for ImportScreen {
fn build(&self, ctx: &mut BuildCtx<ImportState>, view: &View<ImportState>) -> Node {
let import = ctx.bind(
ImportPressed,
reduce_with!(on_import_pressed),
);
Column {
gap: Some(12.0),
children: vec![
Button {
on_press: Some(import),
child: Some(Box::new(Text::new("Import file").into_node())),
..Default::default()
}
.into_node(),
Text::new(if view.state.importing {
"Importing..."
} else {
"Choose a text file to import."
})
.into_node(),
],
..Default::default()
}
.into_node()
}
}
This is worth noticing. The widget tree stays simple because host work is not hidden inside it. The screen shows current state. The reducer owns transitions. The shell performs platform operations.
When to choose a capability, a job, or a resource
This recipe used both a capability and a job because the feature crosses two boundaries.
Use a capability when only the host can do the work. Native file pickers, opening external web addresses, payment handoffs, authentication handoffs, and similar device or operating-system tasks belong here. Fission ships OPEN_URL and PICK_OPEN_FILES; app-specific operations should define their own OperationCapability and register a provider in the shell.
Use a job when the work is app-owned, one-shot, and request-and-response shaped. Parsing imported bytes, saving a document, loading one response from a web service, or generating one export all fit that shape.
Use a resource when the work should exist while a widget subtree exists. A timer that keeps a dashboard fresh, a background watcher that should stay mounted while a screen is visible, or a long-lived service session tied to one route are resource-shaped problems.
The easiest way to remember the difference is to ask who owns the lifetime.
If the host owns the operation, use a capability.
If the app owns one finite task, use a job.
If the runtime should keep work alive for as long as the user interface subtree exists, declare a resource.
What to avoid
Do not put platform calls directly in build(). That will make rebuilds unsafe and unpredictable.
Do not use capabilities as a generic async escape hatch. If the work is really application logic, keep it in a job or service.
Do not store raw platform callback state all over your widget tree. Convert it into app-level state updates in reducers.
Where to go next
If your next feature needs repeated background work instead of a one-shot host action, continue with Keep a timer or service alive. If you want the deeper architecture behind effects, jobs, services, and resources, read Resources and async.