Reactive Components
ReactiveBoundComponent is the base class for UI components that automatically update when
reactive data changes. It integrates Observable and Store patterns with JUCE components,
eliminating manual repaint() calls and ensuring UI stays in sync with state.
Overview
ReactiveBoundComponent provides:
Automatic
repaint()when bound properties/stores changeRAII-based observer cleanup on destruction
Type-safe property binding with templates
Message thread marshalling for UI safety
Zero-copy value passing in
paint()
Location: plugin/src/util/reactive/ReactiveBoundComponent.h
Basic Usage
Inherit from ReactiveBoundComponent
#include "util/reactive/ReactiveBoundComponent.h"
class MyComponent : public Sidechain::Util::ReactiveBoundComponent
{
public:
MyComponent() {
// Bind properties - component will repaint when they change
bindProperty(username);
bindAtomicProperty(isLoading);
}
void paint(juce::Graphics& g) override {
// Read current values (no subscription needed!)
auto name = username.get();
auto loading = isLoading.get();
if (loading) {
g.drawText("Loading...", getBounds(), juce::Justification::centred);
} else {
g.drawText("Hello, " + name, getBounds(), juce::Justification::centred);
}
}
private:
ObservableProperty<juce::String> username{"Guest"};
AtomicObservableProperty<bool> isLoading{false};
};
// Later, when properties change:
myComponent.username.set("Alice"); // Automatically triggers repaint()!
Store Subscriptions
Components commonly subscribe to stores for centralized state:
class PostsFeed : public Sidechain::Util::ReactiveBoundComponent,
public juce::Timer
{
public:
PostsFeed() {
// Get FeedStore singleton
feedStore = &FeedStore::getInstance();
// Subscribe - component will repaint when store state changes
storeUnsubscribe = feedStore->subscribe([this](const FeedStoreState& state) {
// No manual repaint() needed - ReactiveBoundComponent handles it!
});
}
~PostsFeed() override {
// Cleanup subscription
if (storeUnsubscribe)
storeUnsubscribe();
}
void paint(juce::Graphics& g) override {
if (!feedStore)
return;
const auto& state = feedStore->getState();
const auto& currentFeed = state.getCurrentFeed();
if (currentFeed.isLoading) {
drawLoadingState(g);
} else if (!currentFeed.error.isEmpty()) {
drawErrorState(g, currentFeed.error);
} else {
drawPosts(g, currentFeed.posts);
}
}
private:
FeedStore* feedStore = nullptr;
std::function<void()> storeUnsubscribe;
};
Binding Methods
bindProperty()
Bind a regular ObservableProperty:
ObservableProperty<juce::String> text{"Hello"};
bindProperty(text);
bindAtomicProperty()
Bind an AtomicObservableProperty (lock-free):
AtomicObservableProperty<bool> enabled{true};
bindAtomicProperty(enabled);
bindArray()
Bind an ObservableArray with add/remove/change notifications:
ObservableArray<FeedPost> posts;
bindArray(posts);
// Component repaints on:
// - posts.add()
// - posts.remove()
// - posts.set()
bindMap()
Bind an ObservableMap:
ObservableMap<juce::String, User> userCache;
bindMap(userCache);
computed()
Create and bind derived/computed properties:
ObservableProperty<int> age{25};
// Derived property: birthYear = 2024 - age
auto birthYear = computed<int, int>(age, [](int a) {
return 2024 - a;
});
// birthYear is automatically bound to component
Lifecycle & Thread Safety
Automatic Cleanup
All bindings are automatically cleaned up when the component is destroyed:
class MyComponent : public ReactiveBoundComponent {
public:
MyComponent() {
bindProperty(prop1);
bindProperty(prop2);
bindArray(array1);
// ... many bindings
}
~MyComponent() override {
// All bindings automatically unsubscribed via clearBindings()
}
};
Message Thread Marshalling
ReactiveBoundComponent ensures repaint() is always called on the message thread:
// Property updated from background thread
Async::run([]() {
return downloadData();
}, [this](const Data& data) {
myProperty.set(data); // Triggers observer on background thread
});
// ReactiveBoundComponent automatically marshalls to message thread:
void bindProperty(ObservableProperty<T>& property) {
auto unsubscriber = property.observe([this](const T&) {
if (auto* mm = juce::MessageManager::getInstanceWithoutCreating()) {
mm->callAsync([this]() {
repaint(); // SAFE - on message thread
});
} else {
repaint(); // Fallback
}
});
propertyUnsubscribers.push_back(unsubscriber);
}
Real-World Examples
PostCard Component
PostCard displays a single feed post and subscribes to FeedStore for automatic updates:
class PostCard : public Sidechain::Util::ReactiveBoundComponent
{
public:
PostCard() {
// ... initialization
}
void setFeedStore(FeedStore* store) {
feedStore = store;
if (feedStore) {
storeUnsubscribe = feedStore->subscribe([this](const FeedStoreState& state) {
// Find updated post data in store
const auto& currentFeed = state.getCurrentFeed();
for (const auto& p : currentFeed.posts) {
if (p.id == post.id) {
post = p; // Update local copy
break; // ReactiveBoundComponent triggers repaint
}
}
});
}
}
void paint(juce::Graphics& g) override {
// Draw post with current data
drawLikeButton(g, post.likeCount, post.isLiked);
drawComments(g, post.commentCount);
// ... etc
}
private:
FeedPost post;
FeedStore* feedStore = nullptr;
std::function<void()> storeUnsubscribe;
};
// Usage:
// User clicks like → FeedStore.toggleLike() → State changes →
// All PostCards with that post ID update automatically
MessageThread Component
MessageThread displays chat messages and typing indicators reactively:
class MessageThread : public Sidechain::Util::ReactiveBoundComponent,
public juce::Timer
{
public:
MessageThread() {
chatStore = &ChatStore::getInstance();
chatStoreUnsubscribe = chatStore->subscribe([this](const ChatStoreState& state) {
// Trigger resized() to update scroll bounds
if (const auto* channel = state.getCurrentChannel()) {
juce::MessageManager::callAsync([this]() {
resized();
// repaint() called automatically by ReactiveBoundComponent
});
}
});
}
void paint(juce::Graphics& g) override {
if (!chatStore)
return;
const auto* channel = chatStore->getState().getCurrentChannel();
if (!channel)
return;
// Draw messages
for (const auto& message : channel->messages) {
drawMessageBubble(g, message);
}
// Draw typing indicator (real-time from store)
if (!channel->usersTyping.empty()) {
juce::String typingText = channel->usersTyping[0] + " is typing...";
g.drawText(typingText, typingBounds, juce::Justification::centred);
}
}
private:
ChatStore* chatStore = nullptr;
std::function<void()> chatStoreUnsubscribe;
};
// Typing indicators update automatically via ChatStore.usersTyping!
Performance Considerations
Repaint Throttling
ReactiveBoundComponent doesn’t throttle repaints automatically. For high-frequency updates, consider throttling in the store or using a timer:
class MyComponent : public ReactiveBoundComponent, public juce::Timer {
public:
MyComponent() {
bindProperty(highFrequencyProp);
startTimer(16); // ~60fps
}
void timerCallback() override {
// Only repaint on timer, not on every property change
// (subscribers still fire, but we batch repaints)
}
private:
// Override to prevent automatic repaint
void bindProperty(ObservableProperty<T>& property) {
auto unsubscriber = property.observe([this](const T&) {
isDirty = true; // Mark dirty instead of repaint
});
propertyUnsubscribers.push_back(unsubscriber);
}
bool isDirty = false;
};
Selective Updates
For large components, update only changed regions:
void paint(juce::Graphics& g) override {
const auto& state = store->getState();
if (state.headerChanged) {
g.saveState();
g.reduceClipRegion(headerBounds);
drawHeader(g);
g.restoreState();
}
if (state.contentChanged) {
g.saveState();
g.reduceClipRegion(contentBounds);
drawContent(g);
g.restoreState();
}
}
Best Practices
Subscribe in Constructor, Unsubscribe in Destructor
MyComponent() { unsubscribe = store->subscribe([this](const auto& state) { // Handle changes }); } ~MyComponent() override { if (unsubscribe) unsubscribe(); }
Don’t Store Derived State
Always compute from store state in
paint():// BAD: Derived state can get out of sync int cachedLikeCount = 0; void onStoreChange(const FeedStoreState& state) { cachedLikeCount = state.getCurrentFeed().posts.size(); } // GOOD: Compute in paint() void paint(juce::Graphics& g) override { int likeCount = feedStore->getState().getCurrentFeed().posts.size(); g.drawText(juce::String(likeCount), bounds, juce::Justification::centred); }
Avoid Heavy Work in Subscriptions
Subscriptions should trigger UI updates only. Heavy work should be in stores/services:
// BAD: Heavy work in subscription store->subscribe([this](const auto& state) { performExpensiveCalculation(state.data); // Blocks UI! repaint(); }); // GOOD: Heavy work in store action void Store::loadData() { Async::run([]() { return performExpensiveCalculation(); }, [this](const auto& result) { setState({result}); // Light state update }); }
Use Atomic Properties for Audio Thread
Never bind regular properties that are read on audio thread:
// BAD: Mutex lock in audio thread ObservableProperty<float> gain{1.0f}; bindProperty(gain); void processBlock(AudioBuffer<float>& buffer) { buffer.applyGain(gain.get()); // DEADLOCK RISK! } // GOOD: Atomic property for audio thread AtomicObservableProperty<float> gain{1.0f}; bindAtomicProperty(gain); void processBlock(AudioBuffer<float>& buffer) { buffer.applyGain(gain.get()); // Lock-free! }
Migration Guide
From Manual Repaint to Reactive
Before (manual state management):
class OldComponent : public juce::Component {
public:
void setData(const Data& newData) {
data = newData;
repaint(); // Manual repaint
}
void paint(juce::Graphics& g) override {
g.drawText(data.text, getBounds(), juce::Justification::centred);
}
private:
Data data;
};
After (reactive):
class NewComponent : public ReactiveBoundComponent {
public:
NewComponent() {
bindProperty(data); // Automatic repaint on change
}
void setData(const Data& newData) {
data.set(newData); // Triggers repaint automatically
}
void paint(juce::Graphics& g) override {
g.drawText(data.get().text, getBounds(), juce::Justification::centred);
}
private:
ObservableProperty<Data> data;
};
From Callbacks to Store Subscriptions
Before (callback hell):
class OldComponent : public juce::Component {
public:
void loadData() {
networkClient->fetchData([this](const Data& data) {
this->data = data;
repaint();
});
}
private:
Data data;
NetworkClient* networkClient;
};
After (reactive store):
class NewComponent : public ReactiveBoundComponent {
public:
NewComponent() {
store = &DataStore::getInstance();
unsubscribe = store->subscribe([this](const DataStoreState& state) {
// Automatic repaint via ReactiveBoundComponent
});
}
void loadData() {
store->loadData(); // Store handles async, notifies on complete
}
void paint(juce::Graphics& g) override {
const auto& state = store->getState();
if (state.isLoading) {
drawSpinner(g);
} else {
drawData(g, state.data);
}
}
private:
DataStore* store = nullptr;
std::function<void()> unsubscribe;
};
See Also
Store Pattern - Store pattern and state management
Observable Pattern - Observable properties and collections
Data Flow Patterns - Complete data flow examples with components
Threading Model - Thread safety and message thread marshalling