Part 6 of 14 20 Jan 2026 By Raj Patil 40 min read

Part 6: State Management - Local and Global State

Part 6 of the Freya Rust GUI series. Master state management with local state (use_state) and global state (Freya Radio) for sharing data across components and windows.

Intermediate #rust #freya #gui #state #reactivity #radio #tutorial
Building Native GUIs with Rust & Freya 6 / 14

State Management

In Part 5, you learned about hooks for local component state. Now let’s explore how to manage state across your entire application.

[!NOTE] Two Types of State Freya provides two approaches to state management:

  1. Local State (use_state) - Component-scoped, simple to use
  2. Global State (Freya Radio) - Application-wide, fine-grained reactivity

Local State Recap

use_state creates reactive state within a single component.

When to Use Local State

Basic Pattern

fn counter() -> impl IntoElement {
    let mut count = use_state(|| 0);

    rect()
        .direction(Direction::Vertical)
        .gap(16.0)
        .child(label().text(format!("Count: {}", count())))
        .child(
            Button::new()
                .on_press(move |_| *count.write() += 1)
                .child("Increment")
        )
}

Passing State to Children

You can share local state with child components using Readable and Writable:

// Child component that displays a value
#[derive(PartialEq)]
struct DisplayCount {
    count: Readable<i32>,
}

impl Component for DisplayCount {
    fn render(&self) -> impl IntoElement {
        label().text(format!("Count: {}", self.count.read()))
    }
}

// Child component that modifies a value
#[derive(PartialEq)]
struct IncrementButton {
    count: Writable<i32>,
}

impl Component for IncrementButton {
    fn render(&self) -> impl IntoElement {
        Button::new()
            .on_press(move |_| *self.count.write() += 1)
            .child("+")
    }
}

// Parent component
fn parent() -> impl IntoElement {
    let count = use_state(|| 0);

    rect()
        .direction(Direction::Vertical)
        .gap(8.0)
        .child(DisplayCount { count: count.into_readable() })
        .child(IncrementButton { count: count.into_writable() })
}

[!TIP] When This Gets Complicated Passing state through many levels of components is called “prop drilling.” When you find yourself passing the same state through 3+ levels, consider using global state instead.


Global State with Freya Radio

For complex applications that share state across multiple components, use Freya Radio for fine-grained reactivity.

Key Concepts

ConceptDescription
RadioStationCentral hub holding global state
RadioChannelDefines channels for specific state changes
RadioReactive handle to state for a channel
RadioSliceRead-only view of part of the state
RadioSliceMutMutable view of part of the state

[!NOTE] Why “Radio”? Think of it like a radio station broadcasting updates. Components “tune in” to specific channels and only re-render when their channel receives an update.

Step 1: Define Your State

Create a struct that holds your application state:

#[derive(Default, Clone)]
struct AppState {
    count: i32,
    username: String,
    theme: Theme,
}

#[derive(Clone, Default)]
enum Theme {
    #[default]
    Light,
    Dark,
}

Step 2: Define Channels

Channels determine which components re-render when state changes:

#[derive(PartialEq, Eq, Clone, Debug, Copy, Hash)]
enum AppChannel {
    Count,      // Updates when count changes
    Username,   // Updates when username changes
    Theme,      // Updates when theme changes
}

// Implement the RadioChannel trait
impl RadioChannel<AppState> for AppChannel {}

Step 3: Initialize the Station

Initialize the radio station at the root of your app:

fn app() -> impl IntoElement {
    // Initialize global state
    use_init_radio_station::<AppState, AppChannel>(AppState::default);

    rect()
        .expanded()
        .direction(Direction::Vertical)
        .child(Header {})
        .child(Counter {})
        .child(Footer {})
}

Step 4: Use Radio in Components

Now any component can access and modify global state:

#[derive(PartialEq)]
struct Counter {}

impl Component for Counter {
    fn render(&self) -> impl IntoElement {
        // Connect to the Count channel
        let mut radio = use_radio(AppChannel::Count);

        rect()
            .direction(Direction::Horizontal)
            .gap(8.0)
            .child(
                label().text(format!("Count: {}", radio.read().count))
            )
            .child(
                Button::new()
                    .on_press(move |_| {
                        radio.write().count += 1;
                    })
                    .child("+")
            )
    }
}

[!IMPORTANT] Channel-Based Updates The Counter component only re-renders when AppChannel::Count receives an update. Changes to username or theme won’t trigger a re-render here.


Slicing State

For better performance and clearer code, use slices to access specific parts of state.

Read-Only Slices

#[derive(PartialEq)]
struct UsernameDisplay {}

impl Component for UsernameDisplay {
    fn render(&self) -> impl IntoElement {
        let radio = use_radio(AppChannel::Username);
        
        // Get a slice of just the username
        let username_slice = radio.slice_current(|s| &s.username);
        
        label().text(format!("Hello, {}!", username_slice.read()))
    }
}

Mutable Slices

#[derive(PartialEq)]
struct UsernameInput {}

impl Component for UsernameInput {
    fn render(&self) -> impl IntoElement {
        let mut radio = use_radio(AppChannel::Username);
        
        // Get a mutable slice
        let mut username = radio.slice_mut_current(|s| &mut s.username);
        
        Input::new(username.into_writable())
            .placeholder("Enter username")
    }
}

Advanced Channel Patterns

Channel Derivation

Control how updates propagate:

#[derive(PartialEq, Eq, Clone, Debug, Copy, Hash)]
enum TodoChannel {
    All,              // All todos changed
    Todo(usize),      // Specific todo changed
    Filter,           // Filter changed
}

impl RadioChannel<TodoState> for TodoChannel {
    // When a specific todo changes, also notify "All" listeners
    fn derive_channel(self, state: &TodoState) -> Vec<Self> {
        match self {
            TodoChannel::Todo(id) => vec![Self::Todo(id), Self::All],
            TodoChannel::Filter => vec![Self::Filter],
            TodoChannel::All => vec![Self::All],
        }
    }
}

This lets you:


Reducer Pattern

For complex state updates, use the reducer pattern to centralize update logic.

Define Actions

#[derive(Clone)]
enum CounterAction {
    Increment,
    Decrement,
    Reset,
    Set(i32),
}

Implement Reducer

impl DataReducer for CounterState {
    type Channel = CounterChannel;
    type Action = CounterAction;

    fn reduce(&mut self, action: CounterAction) -> ChannelSelection<CounterChannel> {
        match action {
            CounterAction::Increment => {
                self.count += 1;
            }
            CounterAction::Decrement => {
                self.count -= 1;
            }
            CounterAction::Reset => {
                self.count = 0;
            }
            CounterAction::Set(value) => {
                self.count = value;
            }
        }
        ChannelSelection::Current  // Notify current channel
    }
}

Use the Reducer

#[derive(PartialEq)]
struct Counter {}

impl Component for Counter {
    fn render(&self) -> impl IntoElement {
        let mut radio = use_radio(AppChannel::Count);

        rect()
            .direction(Direction::Horizontal)
            .gap(8.0)
            .child(label().text(format!("Count: {}", radio.read().count)))
            .child(
                Button::new()
                    .on_press(move |_| radio.apply(CounterAction::Increment))
                    .child("+")
            )
            .child(
                Button::new()
                    .on_press(move |_| radio.apply(CounterAction::Decrement))
                    .child("-")
            )
            .child(
                Button::new()
                    .on_press(move |_| radio.apply(CounterAction::Reset))
                    .child("Reset")
            )
    }
}

[!TIP] When to Use Reducers Use reducers when:

  • State updates are complex
  • You want to log/debug all state changes
  • You need to support undo/redo
  • Multiple components trigger the same updates

Multi-Window Applications

Freya Radio can share state across multiple windows.

Creating a Global Station

fn main() {
    // Create a global station that can be shared
    let radio_station = RadioStation::create_global(AppState::default);

    launch(
        LaunchConfig::new()
            .with_window(WindowConfig::new_app(MainWindow {
                radio_station: radio_station.clone(),
            }))
            .with_window(WindowConfig::new_app(SettingsWindow {
                radio_station: radio_station.clone(),
            })),
    );
}

Sharing in Windows

struct MainWindow {
    radio_station: RadioStation<AppState, AppChannel>,
}

impl App for MainWindow {
    fn render(&self) -> impl IntoElement {
        // Share the station with this window
        use_share_radio(move || self.radio_station.clone());
        
        let mut radio = use_radio(AppChannel::Count);

        rect()
            .child(label().text(format!("Window 1 Count: {}", radio.read().count)))
            .child(
                Button::new()
                    .on_press(move |_| radio.write().count += 1)
                    .child("Increment")
            )
    }
}

struct SettingsWindow {
    radio_station: RadioStation<AppState, AppChannel>,
}

impl App for SettingsWindow {
    fn render(&self) -> impl IntoElement {
        use_share_radio(move || self.radio_station.clone());
        
        let radio = use_radio(AppChannel::Count);

        rect()
            .child(label().text(format!("Window 2 sees: {}", radio.read().count)))
    }
}

When you increment in the main window, the settings window automatically updates!


Readable and Writable

Use these type-erased abstractions to create flexible components that work with any state source.

Creating Flexible Components

// This component accepts ANY writable string source
#[derive(PartialEq)]
struct NameInput {
    name: Writable<String>,
}

impl Component for NameInput {
    fn render(&self) -> impl IntoElement {
        Input::new(self.name.clone())
            .placeholder("Enter name")
    }
}

// Works with local state
let local_name = use_state(String::new);
NameInput { name: local_name.into_writable() }

// Works with global state slice
let radio = use_radio(AppChannel::Username);
let mut username = radio.slice_mut_current(|s| &mut s.username);
NameInput { name: username.into_writable() }

When to Use Which Approach

ScenarioRecommended Approach
Component-specific datause_state
Simple form stateuse_state
Shared between siblingsFreya Radio
Application-wide settingsFreya Radio
Multi-window syncFreya Radio (global station)
Performance-critical updatesFreya Radio (channels)
Complex update logicFreya Radio (reducers)

Best Practices

1. Start with Local State

Only use global state when sharing is necessary:

// Good: Local state for component-specific UI
let is_hovered = use_state(|| false);

// Good: Global state for app-wide data
let current_user = use_radio(AppChannel::User);

2. Use Channels Wisely

Split channels to minimize unnecessary re-renders:

// Bad: Single channel for everything
enum Channel {
    All,  // Everything re-renders on any change
}

// Good: Specific channels
enum Channel {
    User,
    Settings,
    Messages,
}

3. Prefer set_if_modified

Avoid updates when value hasn’t changed:

// Bad: Always triggers re-render
count.set(new_value);

// Good: Only triggers if value actually changed
count.set_if_modified(new_value);

4. Use Slices for Clarity

Access only the state you need:

// Bad: Accessing entire state
let state = radio.read();
let count = state.count;

// Good: Slice specific data
let count_slice = radio.slice_current(|s| &s.count);
let count = *count_slice.read();

5. Consider Reducers for Complex Logic

// Bad: Scattered update logic
*count.write() += 1;
*count.write() -= 1;

// Good: Centralized in reducer
radio.apply(CounterAction::Increment);
radio.apply(CounterAction::Decrement);

Summary

In this tutorial, you learned:


Previous: Part 5: Hooks ←

Next: Part 7: Event Handling →

In the next tutorial, we’ll explore event handling - how to respond to mouse, keyboard, touch, and other user interactions.