Toggle theme and locale

A production app usually needs two settings screens very early: appearance and language.
The user expects theme changes to update the whole interface consistently. They also expect language changes to affect labels, buttons, and messages without leaving half the app behind.
In Fission, the right way to do this is to keep the user's choice in app state and synchronize the environment that widgets read during component conversion.
This recipe shows that full flow step by step. It also uses the practical translation-loading pattern you should start with first: keep checked-in translation files in your project, load them at compile time with include_str!, and add them to Env during startup.

The problem we are solving

Imagine a settings screen with two controls.
One switches between light and dark theme.
The other switches between English and Spanish.
We want three things to stay true.
First, the user's choices should live in normal app state so reducers and tests can reason about them.
Second, widgets should read the active theme and locale through ViewHandle and Env, not through scattered globals.
Third, the translation files should already be available at startup on macOS, Windows, Linux, Web, Android, iOS, Terminal, Static site, and SSR.

Step 1: put theme and locale in app state

Because the user can change these values during the session, they belong in GlobalState.
use fission::i18n::Locale;
use fission::prelude::*;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ThemeMode {
    Light,
    Dark,
}

#[derive(Debug, Clone)]
pub struct SettingsState {
    pub theme_mode: ThemeMode,
    pub locale: Locale,
}

impl Default for SettingsState {
    fn default() -> Self {
        Self {
            theme_mode: ThemeMode::Light,
            locale: Locale("en-US".into()),
        }
    }
}

impl GlobalState for SettingsState {}
This is the right place for the user's preference because it is part of product behavior. The same choice may be shown in settings, saved later, or covered by tests.

Step 2: load translation files into the environment

Now create the environment that holds translation bundles.
Start by adding checked-in translation files such as i18n/en-US.yaml and i18n/es-ES.yaml.
A minimal pair might look like this:
settings.title: "Settings"
settings.theme: "Theme"
settings.locale: "Language"
settings.theme.light: "Light"
settings.theme.dark: "Dark"
settings.title: "Configuracion"
settings.theme: "Tema"
settings.locale: "Idioma"
settings.theme.light: "Claro"
settings.theme.dark: "Oscuro"
With those files in place, add serde_yaml = "0.9" to your app and load the YAML into Env during startup.
use std::collections::HashMap;

use fission::i18n::{Locale, TranslationBundle};
use fission::prelude::*;

fn load_bundle(locale: &str, raw_yaml: &str) -> TranslationBundle {
    let messages: HashMap<String, String> =
        serde_yaml::from_str(raw_yaml).expect("valid translation yaml");

    TranslationBundle {
        locale: Locale(locale.to_string()),
        messages,
    }
}

fn create_env() -> Env {
    let mut env = Env::default();

    env.i18n.add_bundle(load_bundle(
        "en-US",
        include_str!("../i18n/en-US.yaml"),
    ));
    env.i18n.add_bundle(load_bundle(
        "es-ES",
        include_str!("../i18n/es-ES.yaml"),
    ));

    env
}
This pattern is a good default because it is simple and reliable. The same translation data is compiled into every target, so the first frame can already render in the right language without a network fetch.

Step 3: mirror state into Env with .with_sync_env(...)

The environment now knows about available translation bundles, but it still needs the current user-selected values.
That is what .with_sync_env(...) is for.
It exists on the public shell builders and receives the current app state plus a mutable Env. Use it to synchronize app-driven environment values such as the active theme and locale.
fn main() -> anyhow::Result<()> {
    DesktopApp::new(SettingsApp)
        .with_env(create_env())
        .with_sync_env(|state: &SettingsState, env: &mut Env| {
            env.locale = state.locale.clone();
            env.theme = match state.theme_mode {
                ThemeMode::Light => Theme::default(),
                ThemeMode::Dark => Theme::dark(),
            };
        })
        .run()
}
This line is one of the most important pieces of the recipe.
The user's choices still live in SettingsState. That remains the source of truth.
Env is the shared presentation context that widgets read during build. .with_sync_env(...) keeps those two worlds in sync so the entire widget tree sees one coherent current locale and theme.
Use this hook for values that shape the whole user interface.
Do not use it as a side-effect hook. It is not the place to fetch translations from the network, write settings files, or start background jobs.

Step 4: define actions and reducers for the toggles

Now give the settings screen explicit actions.
#[fission_action]
pub struct SetLightTheme;

#[fission_action]
pub struct SetDarkTheme;

#[fission_action]
pub struct SetEnglish;

#[fission_action]
pub struct SetSpanish;

fn on_set_light_theme(
    state: &mut SettingsState,
    _action: SetLightTheme,
    _ctx: &mut ReducerContext<SettingsState>,
) {
    state.theme_mode = ThemeMode::Light;
}

fn on_set_dark_theme(
    state: &mut SettingsState,
    _action: SetDarkTheme,
    _ctx: &mut ReducerContext<SettingsState>,
) {
    state.theme_mode = ThemeMode::Dark;
}

fn on_set_english(
    state: &mut SettingsState,
    _action: SetEnglish,
    _ctx: &mut ReducerContext<SettingsState>,
) {
    state.locale = Locale("en-US".into());
}

fn on_set_spanish(
    state: &mut SettingsState,
    _action: SetSpanish,
    _ctx: &mut ReducerContext<SettingsState>,
) {
    state.locale = Locale("es-ES".into());
}
These reducers do exactly what you want in a production app: they change state and nothing more. The environment update happens through .with_sync_env(...), which means the setting change still follows the normal reducer-to-render loop.

Step 5: render keyed text and bind the controls

The widget reads state as usual, but user-facing text should come from translation keys rather than hard-coded literals.
pub struct SettingsApp;

impl From<SettingsApp> for Widget {
    fn from(component: SettingsApp) -> Self {
        let (ctx, view) = fission::build::current::<SettingsState>();
        let light = ctx.bind(
            SetLightTheme,
            reduce_with!(on_set_light_theme),
        );
        let dark = ctx.bind(
            SetDarkTheme,
            reduce_with!(on_set_dark_theme),
        );
        let english = ctx.bind(
            SetEnglish,
            reduce_with!(on_set_english),
        );
        let spanish = ctx.bind(
            SetSpanish,
            reduce_with!(on_set_spanish),
        );

        Column {
            gap: Some(12.0),
            children: vec![
                Text::new(TextContent::Key("settings.title".into())).into(),
                Text::new(TextContent::Key("settings.theme".into())).into(),
                Row {
                    children: vec![
                        Button {
                            on_press: Some(light),
                            child: Some(
                                Text::new(TextContent::Key("settings.theme.light".into()))
                                    .into(),
                            ),
                            ..Default::default()
                        }
                        .into(),
                        Button {
                            on_press: Some(dark),
                            child: Some(
                                Text::new(TextContent::Key("settings.theme.dark".into()))
                                    .into(),
                            ),
                            ..Default::default()
                        }
                        .into(),
                    ],
                    ..Default::default()
                }
                .into(),
                Text::new(TextContent::Key("settings.locale".into())).into(),
                Row {
                    children: vec![
                        Button {
                            on_press: Some(english),
                            child: Some(Text::new("English").into()),
                            ..Default::default()
                        }
                        .into(),
                        Button {
                            on_press: Some(spanish),
                            child: Some(Text::new("Español").into()),
                            ..Default::default()
                        }
                        .into(),
                    ],
                    ..Default::default()
                }
                .into(),
            ],
            ..Default::default()
        }
        .into()
    }
}
The crucial line is TextContent::Key(...). That tells the runtime to resolve the text through the loaded translation bundles using the current env.locale.
Because the shell synchronizes state.locale into env.locale, the next reconvert automatically uses the new language.
The same pattern applies to theming. Widgets that read view.theme() or view.env.theme will see the synchronized theme value on the next build.

Why .with_sync_env(...) is the right tool

It can be tempting to branch on view.state().locale or view.state().theme_mode all over the widget tree. Avoid that when you are dealing with app-wide presentation inputs.
Locale and theme are exactly the kind of values that should be centralized. The shell hook keeps the synchronization rule in one place, and widgets can read the environment consistently.
Use .with_sync_env(...) for values like theme, locale, or other global presentation context.
Do not put ordinary product data there. Message lists, editor contents, form drafts, and domain objects still belong in GlobalState and should be read from view.state().

What to test before you ship

Once the wiring works, verify the behavior like a real product team.
Check both theme modes with the same screen content so you can catch contrast and spacing problems.
Check both locales with long strings, not only short English labels, because responsive layout bugs often appear there first.
If a language or theme choice should persist across launches, keep the persisted value as normal app state and restore it through your startup flow rather than inventing a separate hidden path.

Where to go next

If you want the broader explanation of how Env, ViewHandle, and synchronized environment values work, read Input, text, and environment and Theming and internationalization. If you want a repo-backed example after finishing this recipe, the inbox example is the strongest current demonstration of theme and locale switching.

Finished state and checks

By the end of Toggle theme and locale, you should have a runnable change, not just a set of snippets. Run the command shown in the page, exercise the feature once manually, and add or update the smallest test that protects the behavior.
Check
Expected result
Build
cargo check or the relevant fission command finishes without target-specific configuration errors.
Behavior
The screen, package, server route, or test flow described by the recipe is visible and responds to input.
Failure path
At least one denied, missing, invalid, or empty case renders a useful message instead of silently doing nothing.
Next link
The reference page linked from the recipe explains the exact fields, commands, or types used here.
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