Observable Pattern
The Observable pattern provides fine-grained reactive data bindings at the property level. Observables automatically notify subscribers when values change, enabling reactive composition chains and automatic UI updates.
Core Observable Types
ObservableProperty<T>
Thread-safe observable for any copyable type with mutex-based synchronization.
Location: plugin/src/util/reactive/ObservableProperty.h
Basic Usage:
#include "util/reactive/ObservableProperty.h"
// Create observable property
ObservableProperty<juce::String> username{"Guest"};
ObservableProperty<int> age{25};
// Subscribe to changes
auto unsubscribe = username.observe([](const juce::String& newValue) {
DBG("Username changed to: " + newValue);
});
// Update value (triggers observer)
username.set("Alice"); // Prints: "Username changed to: Alice"
// Read current value
juce::String current = username.get();
// Cleanup (RAII pattern)
unsubscribe(); // Stop observing
Features:
Thread-safe get/set with std::mutex
Multiple observers per property
Automatic observer cleanup via RAII
Value comparison to prevent unnecessary notifications
Template-based for any copyable type
AtomicObservableProperty<T>
Lock-free observable for small types (bool, int, float, pointers) with atomic operations.
When to Use:
Audio thread-safe reads (no locks!)
High-frequency updates
Small trivially copyable types
Performance-critical paths
Example:
// Lock-free observable (safe for audio thread reads)
AtomicObservableProperty<bool> isRecording{false};
AtomicObservableProperty<float> gain{1.0f};
// Audio thread can read without blocking
void processBlock(AudioBuffer<float>& buffer) {
if (isRecording.get()) { // Lock-free read!
// Apply gain
buffer.applyGain(gain.get());
}
}
// UI thread can update
void onRecordButtonClicked() {
isRecording.set(true); // Triggers observers on message thread
}
ObservableArray<T>
Observable vector/array with insertion, removal, and modification notifications.
Location: plugin/src/util/reactive/ObservableArray.h
Usage:
ObservableArray<FeedPost> posts;
// Subscribe to item additions
auto unsubAdd = posts.observeItemAdded([](int index, const FeedPost& post) {
DBG("Post added at index " + juce::String(index));
});
// Subscribe to item removals
auto unsubRemove = posts.observeItemRemoved([](int index, const FeedPost& post) {
DBG("Post removed from index " + juce::String(index));
});
// Subscribe to item changes
auto unsubChange = posts.observeItemChanged(
[](int index, const FeedPost& oldPost, const FeedPost& newPost) {
DBG("Post at index " + juce::String(index) + " changed");
}
);
// Modify array
posts.add(newPost); // Triggers observeItemAdded
posts.remove(0); // Triggers observeItemRemoved
posts.set(1, updatedPost); // Triggers observeItemChanged
ObservableMap<K, V>
Observable dictionary/map with key-value change notifications.
Location: plugin/src/util/reactive/ObservableMap.h
Usage:
ObservableMap<juce::String, User> userCache;
// Subscribe to additions
auto unsub = userCache.observeItemAdded([](const juce::String& key, const User& user) {
DBG("User cached: " + key);
});
// Subscribe to changes
userCache.observeItemChanged(
[](const juce::String& key, const User& oldUser, const User& newUser) {
DBG("User updated: " + key);
}
);
// Modify map
userCache.set("user-123", user); // Triggers observeItemAdded or observeItemChanged
userCache.remove("user-123"); // Triggers observeItemRemoved
Reactive Composition
Observables support functional composition with map() and filter() operations.
map() - Transform Values
Create derived observables that transform values:
ObservableProperty<int> age{25};
// Derived property: birthYear = currentYear - age
auto birthYear = age.map<int>([](int a) {
return 2024 - a;
});
// Subscribe to derived property
birthYear->observe([](int year) {
DBG("Birth year: " + juce::String(year));
});
age.set(30); // Triggers: "Birth year: 1994"
Common Use Cases:
// String formatting
ObservableProperty<int> count{0};
auto countText = count.map<juce::String>([](int n) {
return juce::String(n) + " items";
});
// Validation
ObservableProperty<juce::String> email;
auto isValid = email.map<bool>([](const juce::String& e) {
return e.contains("@") && e.contains(".");
});
// Computation chains
ObservableProperty<float> temperature{20.0f};
auto fahrenheit = temperature.map<float>([](float c) {
return c * 9.0f / 5.0f + 32.0f;
});
filter() - Conditional Updates
Create observables that only update when predicate is true:
ObservableProperty<int> value{0};
// Only positive values
auto positiveOnly = value.filter([](int v) {
return v > 0;
});
positiveOnly->observe([](int v) {
DBG("Positive value: " + juce::String(v));
});
value.set(-5); // No notification (filtered out)
value.set(10); // Triggers: "Positive value: 10"
Combining Observables
Combine multiple observables into computed properties:
ObservableProperty<juce::String> firstName{"John"};
ObservableProperty<juce::String> lastName{"Doe"};
// Computed full name
auto fullName = firstName.map<juce::String>([&lastName](const juce::String& first) {
return first + " " + lastName.get();
});
fullName->observe([](const juce::String& name) {
DBG("Full name: " + name);
});
firstName.set("Jane"); // Triggers: "Full name: Jane Doe"
Performance Characteristics
ObservableProperty<T>
Set: O(n) - notifies all subscribers
Get: O(1) with mutex lock
Memory: Mutex + value + vector of observers
Thread Safety: Full (std::mutex)
AtomicObservableProperty<T>
Set: O(n) - atomic write + notify subscribers
Get: O(1) lock-free read
Memory: std::atomic<T> + vector of observers
Thread Safety: Lock-free reads, synchronized writes
ObservableArray<T>
Add: O(n) for vector + O(m) for subscribers
Remove: O(n) for vector + O(m) for subscribers
Memory: std::vector<T> + observer vectors
Thread Safety: Mutex protected
ObservableMap<K, V>
Set: O(log n) for map + O(m) for subscribers
Get: O(log n)
Memory: std::map<K,V> + observer vectors
Thread Safety: Mutex protected
Best Practices
Use RAII for Unsubscribe
Always store unsubscribe functions and call in destructor:
class MyComponent { public: MyComponent() { unsubscribe = property.observe([this](const auto& value) { // Handle change }); } ~MyComponent() { if (unsubscribe) unsubscribe(); } private: std::function<void()> unsubscribe; };
Avoid Circular Dependencies
Don’t create observer cycles:
// BAD: Circular dependency prop1.observe([&prop2](auto v) { prop2.set(v + 1); }); prop2.observe([&prop1](auto v) { prop1.set(v - 1); }); // Infinite loop!
Use Atomic for Audio Thread
Never use regular ObservableProperty on audio thread:
// BAD: Mutex lock in audio thread ObservableProperty<float> gain; void processBlock(AudioBuffer<float>& buffer) { buffer.applyGain(gain.get()); // BLOCKS! } // GOOD: Lock-free atomic AtomicObservableProperty<float> gain; void processBlock(AudioBuffer<float>& buffer) { buffer.applyGain(gain.get()); // Lock-free! }
Minimize Observer Work
Keep observers lightweight - heavy work should be async:
// BAD: Heavy work in observer property.observe([](const auto& value) { performExpensiveComputation(value); // Blocks all other observers! }); // GOOD: Async heavy work property.observe([](const auto& value) { Async::run([value]() { return performExpensiveComputation(value); }, [](const auto& result) { updateUI(result); }); });
Prefer Store-Level Subscriptions
For large state changes, subscribe to stores rather than individual properties:
// Less efficient: Many property subscriptions username.observe([](auto v) { repaint(); }); email.observe([](auto v) { repaint(); }); avatar.observe([](auto v) { repaint(); }); // Better: Single store subscription userStore.subscribe([](const UserStoreState& state) { // Update all at once repaint(); });
Common Patterns
Loading States
ObservableProperty<bool> isLoading{false};
ObservableProperty<juce::String> errorMessage{""};
ObservableProperty<std::vector<Item>> items;
isLoading.observe([this](bool loading) {
if (loading) showSpinner();
else hideSpinner();
});
errorMessage.observe([this](const juce::String& error) {
if (error.isNotEmpty()) showError(error);
});
Form Validation
ObservableProperty<juce::String> email;
ObservableProperty<juce::String> password;
auto emailValid = email.map<bool>([](const juce::String& e) {
return e.contains("@");
});
auto passwordValid = password.map<bool>([](const juce::String& p) {
return p.length() >= 8;
});
// Enable submit button when both valid
auto canSubmit = emailValid->map<bool>([&passwordValid](bool eValid) {
return eValid && passwordValid->get();
});
canSubmit->observe([this](bool enabled) {
submitButton.setEnabled(enabled);
});
Real-Time Sync
ObservableProperty<int> localLikeCount{0};
// WebSocket update
websocket.on("like_update", [&localLikeCount](const juce::var& data) {
localLikeCount.set(data["count"]); // Triggers UI update
});
// Observer updates UI automatically
localLikeCount.observe([this](int count) {
likeCountLabel.setText(juce::String(count), juce::dontSendNotification);
repaint();
});
See Also
Store Pattern - Store-level state management
Reactive Components - ReactiveBoundComponent integration
Threading Model - Thread safety and constraints
Data Flow Patterns - Complete reactive data flow examples