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:
- Local State (
use_state) - Component-scoped, simple to use- 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
- Form inputs
- Toggle switches
- Component-specific UI state (expanded/collapsed, hovered)
- Temporary values
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
| Concept | Description |
|---|---|
RadioStation | Central hub holding global state |
RadioChannel | Defines channels for specific state changes |
Radio | Reactive handle to state for a channel |
RadioSlice | Read-only view of part of the state |
RadioSliceMut | Mutable 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
Countercomponent only re-renders whenAppChannel::Countreceives an update. Changes tousernameorthemewon’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:
- Listen to all changes with
TodoChannel::All - Listen to specific item changes with
TodoChannel::Todo(5)
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
| Scenario | Recommended Approach |
|---|---|
| Component-specific data | use_state |
| Simple form state | use_state |
| Shared between siblings | Freya Radio |
| Application-wide settings | Freya Radio |
| Multi-window sync | Freya Radio (global station) |
| Performance-critical updates | Freya Radio (channels) |
| Complex update logic | Freya 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:
- Local state with
use_statefor component-specific data - Passing state to children with
ReadableandWritable - Global state with Freya Radio for shared application data
- RadioStation, RadioChannel, Radio - the core Radio concepts
- State slices for accessing specific parts of state
- Channel derivation for controlling update propagation
- Reducer pattern for complex state updates
- Multi-window state sharing with global stations
- When to use local vs global state
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.