Fission for Tauri developers

Tauri and Fission both appeal to developers who want Rust in desktop/mobile app architecture. Tauri keeps a web frontend plus Rust commands. Fission makes the UI model itself Rust-first and shared across more target shells.
The main shift is that Fission does not treat Rust as only the backend behind a webview. State, widgets, reducers, routing, theme, and tests are all part of the shared Rust app model.
This page uses the same todo app shape as the React guide: a checklist with a draft field, add/toggle actions, loading from an outside source, token-driven styling, routing, and tests.

The state ownership rule

Before translating syntax, decide where state belongs:
•
Use #[fission_component] and #[local_state] for retained UI memory owned by one widget identity.
•
Use GlobalState for product truth: todos, routing, persistence, sync state, user preferences, and shared filters.
•
Use reducers for named user intent and state transitions.
•
Use jobs, services, resources, capabilities, or runtime effects for outside work.
•
Add semantic identifiers to interactive widgets and meaningful regions so tests and accessibility tools can target product meaning instead of coordinates.

1. Static UI

{"id":"fission-tabs-1","tabs":[{"index":0,"label":"Tauri","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-1","index":0,"label":"Tauri","value":"source"}
  return (
    <main>
      <h1>Ship checklist</h1>
      <TodoRow title="Write release notes" done={false} />
      <TodoRow title="Test Android package" done />
    </main>
  );
}
{"id":"fission-tabs-1","index":1,"label":"Fission","value":"fission"}
use fission::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Todo {
    pub id: u64,
    pub title: String,
    pub done: bool,
}

pub struct TodoApp;

impl From<TodoApp> for Widget {
    fn from(_app: TodoApp) -> Widget {
        let todos = vec![
            Todo { id: 1, title: "Write release notes".into(), done: false },
            Todo { id: 2, title: "Test Android package".into(), done: true },
        ];

        let rows = todos.into_iter().map(|todo| {
            let status = if todo.done { "Done" } else { "Open" };
            Row {
                gap: Some(12.0),
                children: widgets![Text::new(status), Text::new(todo.title)],
                ..Default::default()
            }
            .into()
        });

        Container::new(Column {
            gap: Some(12.0),
            children: std::iter::once(Text::new("Ship checklist").size(28.0).weight(800).into())
                .chain(rows)
                .collect(),
            ..Default::default()
        })
        .padding_all(24.0)
        .into()
    }
}
{"id":"fission-tabs-1"}
The Fission side is a concrete widget value. Conversion reads inputs and returns a widget tree; it is not the place to start outside work or hide product state.

2. Product state and interaction

{"id":"fission-tabs-2","tabs":[{"index":0,"label":"Tauri","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-2","index":0,"label":"Tauri","value":"source"}
  const [draft, setDraft] = useState("");
  const [todos, setTodos] = useState<Todo[]>([]);

  async function addDraftTodo() {
    const todo = await invoke<Todo>("add_todo", { title: draft });
    setTodos((items) => [...items, todo]);
    setDraft("");
  }
}
{"id":"fission-tabs-2","index":1,"label":"Fission","value":"fission"}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TodoState {
    pub draft: String,
    pub todos: Vec<Todo>,
    pub next_id: u64,
    pub current_path: String,
}

impl Default for TodoState {
    fn default() -> Self {
        Self {
            draft: String::new(),
            todos: Vec::new(),
            next_id: 1,
            current_path: "/".into(),
        }
    }
}

impl GlobalState for TodoState {}

#[fission_reducer(SetDraft)]
fn set_draft(state: &mut TodoState, ctx: &mut ReducerContext<TodoState>) {
    let Some(change) = ctx.input.text_change() else {
        return;
    };
    state.draft = change.new_text.clone();
}

#[fission_reducer(AddDraftTodo)]
fn add_draft_todo(state: &mut TodoState) {
    let title = state.draft.trim();
    if title.is_empty() {
        return;
    }

    state.todos.push(Todo {
        id: state.next_id,
        title: title.to_string(),
        done: false,
    });
    state.next_id += 1;
    state.draft.clear();
}

#[fission_reducer(ToggleTodo)]
fn toggle_todo(state: &mut TodoState, id: u64) {
    if let Some(todo) = state.todos.iter_mut().find(|todo| todo.id == id) {
        todo.done = !todo.done;
    }
}
pub struct TodoScreen;

impl From<TodoScreen> for Widget {
    fn from(_screen: TodoScreen) -> Widget {
        let (ctx, view) = fission::build::current::<TodoState>();
        let update_draft = with_reducer!(ctx, SetDraft, set_draft);
        let add_todo = with_reducer!(ctx, AddDraftTodo, add_draft_todo);

        let rows = view.state().todos.iter().map(|todo| {
            let toggle = with_reducer!(ctx, ToggleTodo(todo.id), toggle_todo);
            Row {
                gap: Some(12.0),
                children: widgets![
                    Checkbox {
                        checked: todo.done,
                        on_toggle: Some(toggle),
                        ..Default::default()
                    }
                    .semantics_identifier(format!("todo.toggle.{}", todo.id)),
                    Text::new(todo.title.clone()),
                ],
                ..Default::default()
            }
            .semantics_identifier(format!("todo.row.{}", todo.id))
            .into()
        });

        Column {
            gap: Some(16.0),
            children: widgets![
                Text::new("Ship checklist").size(28.0).weight(800),
                Row {
                    gap: Some(12.0),
                    children: widgets![
                        TextInput {
                            id: Some(WidgetId::explicit("todo-title")),
                            value: view.state().draft.clone(),
                            label: Some("New task".into()),
                            placeholder: Some("Add a task".into()),
                            on_input: Some(update_draft),
                            ..Default::default()
                        }
                        .semantics_identifier("todo.title"),
                        Button {
                            child: Some(Text::new("Add task").into()),
                            on_press: Some(add_todo),
                            ..Default::default()
                        }
                        .semantics_identifier("todo.add"),
                    ],
                    ..Default::default()
                },
                Column { gap: Some(10.0), children: rows.collect(), ..Default::default() },
            ],
            ..Default::default()
        }
        .into()
    }
}
{"id":"fission-tabs-2"}
Fission makes the state transition explicit. The button does not mutate a captured local variable; it dispatches a named action and the reducer updates product state.

3. Outside work

{"id":"fission-tabs-3","tabs":[{"index":0,"label":"Tauri","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-3","index":0,"label":"Tauri","value":"source"}
#[tauri::command]
async fn load_todos(state: tauri::State<'_, TodoStore>) -> Result<Vec<Todo>, String> {
    state.load().await.map_err(|err| err.to_string())
}

const todos = await invoke<Todo[]>("load_todos");
{"id":"fission-tabs-3","index":1,"label":"Fission","value":"fission"}
use fission::core::{JobRef, JobSpec};
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LoadTodosRequest;

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApiError {
    pub message: String,
}

pub struct LoadTodosJob;

impl JobSpec for LoadTodosJob {
    type Request = LoadTodosRequest;
    type Ok = Vec<Todo>;
    type Err = ApiError;

    const NAME: &'static str = "todo.load";
}

pub const LOAD_TODOS_JOB: JobRef<LoadTodosJob> = JobRef::new(LoadTodosJob::NAME);
#[fission_reducer(LoadTodos)]
fn load_todos(state: &mut TodoState, ctx: &mut ReducerContext<TodoState>) {
    state.loading = true;
    ctx.effects
        .app(LOAD_TODOS_JOB, LoadTodosRequest)
        .on_ok(ctx.effects.bind(TodosLoaded, reduce_with!(todos_loaded)))
        .on_err(ctx.effects.bind(TodosFailed, reduce_with!(todos_failed)));
}
{"id":"fission-tabs-3"}
Do not perform outside work during component conversion. Reducers request work; jobs, services, resources, or capabilities perform it; results return as actions.

4. Styling and theme

{"id":"fission-tabs-4","tabs":[{"index":0,"label":"Tauri","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-4","index":0,"label":"Tauri","value":"source"}
<button className="rounded-xl border bg-white px-4 py-3 shadow-sm">
  <input type="checkbox" checked={todo.done} readOnly />
  <span>{todo.title}</span>
</button>
{"id":"fission-tabs-4","index":1,"label":"Fission","value":"fission"}
pub struct TodoRow {
    pub todo: Todo,
}

impl From<TodoRow> for Widget {
    fn from(row: TodoRow) -> Widget {
        let (ctx, view) = fission::build::current::<TodoState>();
        let tokens = &view.env().theme.tokens;
        let toggle = with_reducer!(ctx, ToggleTodo(row.todo.id), toggle_todo);

        Container::new(Row {
            gap: Some(tokens.spacing.s),
            children: widgets![
                Checkbox {
                    checked: row.todo.done,
                    on_toggle: Some(toggle),
                    ..Default::default()
                }
                .semantics_identifier(format!("todo.toggle.{}", row.todo.id)),
                Text::new(row.todo.title)
                    .size(tokens.typography.font_size_base)
                    .color(tokens.colors.text_primary),
            ],
            ..Default::default()
        })
        .bg(tokens.colors.surface)
        .border(tokens.colors.border, 1.0)
        .border_radius(tokens.radii.large)
        .padding([tokens.spacing.m, tokens.spacing.m, tokens.spacing.s, tokens.spacing.s])
        .into()
    }
}
{"id":"fission-tabs-4"}
In Fission, the active theme is runtime environment. Read token values from view.env().theme.tokens and apply them through widget fields. That keeps the UI target-lowerable across native, web, mobile, terminal, static, and SSR shells.

5. Routing

{"id":"fission-tabs-5","tabs":[{"index":0,"label":"Tauri","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-5","index":0,"label":"Tauri","value":"source"}
<BrowserRouter>
  <Routes>
    <Route path="/" element={<TodoApp />} />
    <Route path="/todos/:id" element={<TodoDetail />} />
  </Routes>
</BrowserRouter>
{"id":"fission-tabs-5","index":1,"label":"Fission","value":"fission"}
use fission::widgets::{Route, Router};
use std::sync::Arc;

#[fission_reducer(NavigateTo)]
fn navigate_to(state: &mut TodoState, path: String) {
    state.current_path = path;
}

pub struct TodoRoutes;

impl From<TodoRoutes> for Widget {
    fn from(_routes: TodoRoutes) -> Widget {
        let (_, view) = fission::build::current::<TodoState>();

        Router::<TodoState> {
            current_path: view.state().current_path.clone(),
            routes: vec![
                Route {
                    path: "/".into(),
                    builder: Arc::new(|_, _, _| TodoScreen.into()),
                },
                Route {
                    path: "/todos/:id".into(),
                    builder: Arc::new(|_, _, params| TodoDetail {
                        id: params["id"].parse().unwrap_or(0),
                    }
                    .into()),
                },
            ],
            not_found: Some(Arc::new(|_, _, _| Text::new("Not found").into())),
        }
        .into()
    }
}
{"id":"fission-tabs-5"}
Use Fission's native Router and RouterParams. Shell URLs, deep links, static routes, server routes, and terminal route-like state should normalize into the same app-owned route state instead of each target inventing a different navigation model.

6. Testing

{"id":"fission-tabs-6","tabs":[{"index":0,"label":"Tauri","value":"source"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-6","index":0,"label":"Tauri","value":"source"}
await page.getByLabel('New task').fill('Publish release');
await page.getByRole('button', { name: 'Add task' }).click();
await expect(page.getByText('Publish release')).toBeVisible();
{"id":"fission-tabs-6","index":1,"label":"Fission","value":"fission"}
use fission_test_driver::{LiveTestClient, SelectorQuery};

#[test]
#[ignore]
fn user_can_add_a_todo_in_a_real_shell() {
    let control_port = reserve_control_port();
    let mut child = launch_todo_app(control_port);
    let client = LiveTestClient::connect(control_port);

    client.wait_for_ready(15_000).expect("app ready");
    client
        .fill_text_semantic_identifier("todo.title", "Publish release")
        .expect("fill draft");
    client
        .tap_semantic_identifier("todo.add")
        .expect("add todo");
    client
        .wait_for_visible(SelectorQuery::label("Publish release"), 5_000)
        .expect("todo visible");
    client
        .screenshot(".artifacts/screenshots/todos/added.png")
        .expect("screenshot");

    client.quit().expect("quit app");
    let _ = child.wait();
}
{"id":"fission-tabs-6"}
Keep fast reducer tests for business rules. Use headless fission-test for widget/layout/semantics assertions. Use LiveTestClient and /cmd for real shell input, focus, screenshots, selectors, and target behavior.

Vocabulary map

Tauri concept
Closest Fission concept
Important difference
Web frontend
Fission widget tree
The UI is Rust-native, not a webview app model.
#[tauri::command]
Job, service, resource, or capability
Calls are typed runtime work requested by reducers.
Plugin
Capability/provider/native module
Host integration is explicit and target-aware.
tauri.conf.json bundle config
fission.toml targets/package config
Package artifacts produce manifest handoffs.
Web tests
LiveTestClient and /cmd
Tests target semantics rather than DOM selectors.

Common instincts to adjust

Do not split product state between a frontend store and Rust command state unless there is a real ownership reason. Fission product state should be one visible app model.
Do not turn every host operation into a generic stringly IPC call. Prefer typed jobs, services, capabilities, and reducers.
Keep the Tauri instinct that packaging, signing, and native capabilities are first-class app concerns.
Read Testing Fission apps for unit, headless integration, LiveTest, and /cmd workflows.
Read State, handles, and providers for the durable-state, local-state, provider, and environment decision model.
Read Resources and async for jobs, services, resources, capabilities, and reducer effects.