App structure

A Fission app is easiest to maintain when each file has one job. State describes what the product knows. Reducers describe how that state changes. Widgets describe what the user sees. Shell entrypoints start the same shared app on macOS, Windows, Linux, Web, Android, iOS, Terminal, Static site, or SSR targets.
By the end of this guide, you should know where to put the first screen, where to put actions and reducers, when to split widgets into modules, and what should stay out of main.rs.

1. Start from the generated files

A new app usually starts like this:
my-app/
  Cargo.toml
  fission.toml
  src/
    app.rs
    lib.rs
    main.rs
  platforms/
    web/
    android/
    ios/
The exact target folders depend on which targets you add. The important split is stable:
Path
Responsibility
src/app.rs
The shared product app: state, root widget, reducers, selectors, and modules used by every target.
src/lib.rs
Shared exports and target entry helpers used by generated web/mobile hosts.
src/main.rs
The default desktop binary entrypoint. Keep it small.
Fission project metadata, targets, capabilities, site/release references, and package configuration.
platforms/<target>/
Generated host files for web, Android, iOS, or other targets. Review them, but do not move product logic there.
If you remember one rule, make it this: product behavior belongs in shared Rust modules, not in generated platform folders.

2. Give the app state a clear shape

Begin with one state struct. Add fields because the UI needs to render them or a reducer needs to update them.
#[derive(Default)]
pub struct TodoState {
    pub draft: String,
    pub items: Vec<TodoItem>,
    pub filter: TodoFilter,
    pub saving: bool,
    pub error: Option<String>,
}
Avoid storing the same fact in two forms unless there is a strong reason. If a value can be derived cheaply from state, use a selector or helper instead of storing another copy.

3. Put actions and reducers next to the state they change

Reducers should be easy to find during code review. For a small app, keeping actions and reducers in src/app.rs is fine. As the app grows, move them into modules by feature:
src/
  app.rs
  todo/
    mod.rs
    state.rs
    actions.rs
    reducers.rs
    selectors.rs
    widgets.rs
A compact reducer uses #[fission_reducer]:
#[fission_reducer(SetDraft)]
fn on_set_draft(state: &mut TodoState, draft: String) {
    state.draft = draft;
}

#[fission_reducer(AddTodo)]
fn on_add_todo(state: &mut TodoState) {
    let text = state.draft.trim();
    if text.is_empty() {
        return;
    }

    state.items.push(TodoItem::new(text));
    state.draft.clear();
}
Use #[fission_action] for manual action structs when the action is part of a public API, reused across several modules, or needs a documented data shape. Use #[fission_reducer] for the common case where one reducer owns one action.

4. Build screens from real widget structs

Prefer reusable widget structs over large helper functions that return Widget directly. This keeps examples and production apps aligned with the framework model.
pub struct TodoScreen;

impl From<TodoScreen> for Widget {
    fn from(component: TodoScreen) -> Self {
        let (ctx, view) = fission::build::current::<TodoState>();
        let add = with_reducer!(ctx, AddTodo, on_add_todo);

        Column {
            children: vec![
                TodoComposer { on_submit: add }.into(),
                TodoList::new(view.state().visible_items()).into(),
            ],
            ..Default::default()
        }
        .into()
    }
}
Small helper functions are still useful for pure formatting or repeated literal values. They should not become a second component system beside Widget conversions.

5. Use selectors for derived view data

A selector is a small piece of code that turns product state into view-ready data. It keeps component conversion readable and avoids mutating state while building the UI.
pub struct TodoListItemVm {
    pub title: String,
    pub completed: bool,
}

fn visible_todos(state: &TodoState) -> Vec<TodoListItemVm> {
    state.items
        .iter()
        .filter(|item| state.filter.allows(item))
        .map(|item| TodoListItemVm {
            title: item.title.clone(),
            completed: item.completed,
        })
        .collect()
}
Use selectors for formatted strings, filtered lists, button-disabled decisions, empty states, and responsive view models. Do not use selectors to perform host work, mutate state, or dispatch actions.

6. Keep entrypoints small

src/main.rs should start the shell and then get out of the way:
use fission::prelude::*;
use my_app::App;

fn main() -> anyhow::Result<()> {
    DesktopApp::new(App).run()
}
As targets are added, generated host entrypoints should also call into the shared app instead of duplicating screens or reducers. That keeps macOS, Windows, Linux, Web, Android, iOS, Terminal, and test entrypoints running the same product behavior.

7. Add target-specific code only at the host boundary

Some code really is target-specific: notification providers, camera providers, URL scheme registration, native packaging files, browser service workers, or platform release metadata. Put that code in the shell/provider layer or generated platform files, not in random widgets.
If a widget needs to know whether a feature is available, store the result in state after a capability check and render from that state. Do not scatter cfg(target_os = ...) through product widgets unless the widget itself truly cannot exist on the target.

8. Split modules when review becomes hard

A file is too large when a reviewer cannot quickly answer these questions:
Which state does this feature own?
Which actions can change it?
Which reducers handle those actions?
Which widgets render that state?
Which effects leave the app and enter the host?
Split by feature before the file becomes a dumping ground. A good feature module has its state, reducers, selectors, and widgets close together. Shared design-system components can live in a separate components/ module.

Where to go next

Read Runtime model for the state-action-reducer loop. Read State, handles, and providers before deciding whether a value belongs in GlobalState, local widget state, Env, or a provider. Read Layout and widgets for screen composition. Read Platform capabilities before adding host-owned features.

What you should have working

After this guide, you should be able to explain where App structure fits in the Fission lifecycle, identify the file or component you changed, run the relevant fission command, and verify the result in a real target or test.

Common mistakes to check

Symptom
What to check first
The app compiles but nothing changes
Confirm the component is actually used by the route or shell target you are running.
An action fires but state does not update
Confirm the reducer is registered with the same action value that the UI dispatches.
A platform feature reports unsupported
Confirm the target has the capability in fission.toml and the shell registered the provider.
The page works on one target but not another
Run fission doctor, then inspect target-specific configuration and feature flags.
Tests are hard to write
Split the product rule into a reducer test first, then add a UI or shell smoke test for the integration.
Fission
A cross-platform, GPU-accelerated user interface framework for Rust. MIT licensed.
Copyright (c) 2026 Fission
Ready to use today. Widget APIs are expected to remain stable; some runtime and shell APIs may change before 1.0.0.
Fission 0.7.0