Dioxus Signals vs Stores: State Management Best Practices
Use Signals for simple copy-on-write primitives with automatic reactivity, and Stores for complex mutable data structures requiring fine-grained subscriptions.
Dioxus provides two complementary primitives for managing reactive state in Rust applications. Understanding when to use Signals versus Stores is essential for building performant, maintainable UIs with the DioxusLabs/dioxus framework. Choosing the right abstraction prevents unnecessary re-renders and ensures type-safe state sharing across components.
When to Use Signals in Dioxus
Signals are copy-on-write values that automatically track which components read them. When the value changes, all subscribing scopes re-run immediately.
Automatic Dependency Tracking
In packages/signals/src/signal.rs, the Signal type implements automatic subscription tracking through the read() method. When a component calls *signal.read(), the current scope registers itself as a subscriber. Subsequent calls to *signal.write() automatically notify all registered scopes to re-render.
Signals excel with copyable primitives: integers, booleans, strings, and small immutable data. They use a macro-generated CopyValue wrapper that avoids heap allocations, making them extremely lightweight for frequent updates.
Thread Safety Considerations
By default, Signal<T> uses UnsyncStorage and is restricted to the creating thread. For cross-thread access, use SyncSignal<T> (aliased as Signal<T, SyncStorage>). Accessing an unsynced signal from another thread will panic at runtime.
use dioxus::prelude::*;
// Local component state
#[component]
fn Counter() -> Element {
let count = Signal::new(0);
rsx! {
button {
onclick: move |_| *count.write() += 1,
"Count: {count}"
}
}
}
// Global app-wide state
static GLOBAL_COUNT: GlobalSignal<i32> = Signal::global(|| 0);
#[component]
fn GlobalCounter() -> Element {
rsx! {
button {
onclick: move |_| *GLOBAL_COUNT.write() += 1,
"Global: {GLOBAL_COUNT}"
}
}
}
When to Use Stores for Complex State
Stores are containers for complex data that implement the Subscriptions trait. Defined in packages/stores/src/store.rs, they expose fine-grained APIs like subscribe and listen for precise control over update notifications.
Fine-Grained Subscriptions
Unlike Signals, which notify all subscribers on any change, Stores allow partial updates. In packages/stores/src/subscriptions.rs, the subscription logic ensures only listeners monitoring specific fields receive notifications. This prevents cascading re-renders when unrelated data changes.
Handling Mutable Collections
Stores are ideal for Vec<T>, HashMap<K, V>, or large structs. Because they mutate in place rather than cloning the entire container, they remain efficient for large datasets that change frequently.
use dioxus::prelude::*;
use std::sync::Arc;
#[derive(Default, Clone)]
struct TodoList {
items: Vec<String>,
filter: String,
}
#[component]
fn TodoApp() -> Element {
let store = use_store(|| Store::new(TodoList::default()));
// Subscribes to entire store
let todos = store.read().items.clone();
rsx! {
ul {
todos.iter().map(|t| rsx!{ li { "{t}" } })
}
input {
oninput: move |e| {
// Mutates in place without cloning the vector
store.write().items.push(e.value.clone());
}
}
}
}
// Partial subscription - only re-renders when items length changes
#[component]
fn TodoCount() -> Element {
let store = use_store::<TodoList>();
let count = store.listen(|s| s.items.len());
rsx! { div { "Items: {count}" } }
}
Comparing Signals and Stores
| Aspect | Signal | Store |
|---|---|---|
| Concept | Copy-on-write with automatic tracking | Mutable container with explicit subscription |
| Best For | Primitives, simple state | Collections, complex structs, async data |
| Reading | *signal.read() subscribes automatically |
store.read().field (full subscription) or store.listen() (partial) |
| Writing | *signal.write() = value |
store.write().field = value |
| Performance | Zero-cost for copyable types | Efficient for large mutable structures |
| Thread Safety | Requires SyncSignal for cross-thread |
Always thread-safe with Send + Sync types |
Best Practices for Dioxus State Management
-
Start with Signals for copyable primitives like counters, flags, and text inputs. They provide the least boilerplate and most ergonomic reactivity.
-
Upgrade to Stores when handling non-copyable types like
Vec<T>orHashMap<K, V>, or when you need fine-grained subscriptions to specific fields. -
Avoid
write_silenton Signals. This legacy method disables automatic updates and causes UI drift. Usepeek()for non-reactive reads instead. -
Manage scope ownership carefully. Signals created inside
use_signaldrop when the component unmounts. For long-lived state, useSignal::globalor provide the signal viause_context_providerin a higher-level component. -
Use Memoization for derived data. Create computed values with
Memo::new(defined inpackages/signals/src/memo.rs) rather than duplicating logic across multiple Signals. -
Select proper thread safety. Use
SyncSignalorStorewithSyncStoragewhen mutating state from background threads. Default Signals will panic if accessed across thread boundaries. -
Debug effectively using
peek()to inspect Signal values without triggering re-renders, andstore.listen()callbacks to log subscription changes.
Summary
- Signals in
packages/signals/src/signal.rsprovide zero-boilerplate reactivity for simple, copyable types through automatic dependency tracking. - Stores in
packages/stores/src/store.rsoffer fine-grained control over complex, mutable state via the subscription system defined insubscriptions.rs. - Start with Signals for local UI state and migrate to Stores when you need partial updates, mutable collections, or cross-thread access.
- Always match your storage type—
UnsyncStoragefor single-threaded use,SyncStoragefor shared thread access—to your application's concurrency requirements.
Frequently Asked Questions
Can I use both Signals and Stores in the same component?
Yes. Many applications combine both primitives: Signals for local UI state like form inputs or toggles, and Stores for shared application state like data collections or cached API responses. Import both use_signal and use_store hooks to manage different state lifecycles within a single component.
Why does my Signal panic when accessed from a background thread?
Standard Signal<T> uses UnsyncStorage by default, restricting access to the creating thread. To share state across threads, use SyncSignal<T> (or Signal<T, SyncStorage>). Alternatively, use a Store which is inherently thread-safe when the underlying type implements Send + Sync.
How do I prevent unnecessary re-renders with large datasets?
Use a Store instead of a Signal. While Signals clone the entire value on every write, Stores mutate data in place and only notify specific subscribers. Use store.listen(|state| state.specific_field) to subscribe to individual fields rather than the entire structure, minimizing render cycles.
What is the difference between read() and peek() in Dioxus Signals?
The read() method subscribes the current scope to future updates, causing the component to re-render when the value changes. The peek() method retrieves the current value without establishing a subscription, useful for debugging or conditional logic that should not trigger reactive updates.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →