ObservableEvents
ReactiveMarbles.ObservableEvents is a source generator that converts .NET events into observable sequences, making them easier to compose, filter, and combine with other reactive operations.
Overview
Instead of manually subscribing to events and managing event handlers, ObservableEvents automatically generates extension methods that convert events to IObservable<T> sequences. This allows you to use the full power of Rx operators with any .NET event.
Installation
Install the package via NuGet:
dotnet add package ReactiveMarbles.ObservableEvents.SourceGenerator
Or via Package Manager:
<PackageReference Include="ReactiveMarbles.ObservableEvents.SourceGenerator" Version="*" />
How It Works
ObservableEvents is a source generator that runs at compile-time. It analyzes your project's referenced assemblies and generates extension methods for all public events. These extension methods are named Events() and return an IObservable<EventPattern<TEventArgs>>.
Generated Code Pattern
For any event like:
public event EventHandler<MouseEventArgs> MouseMove;
The generator creates:
public static IObservable<EventPattern<MouseEventArgs>> MouseMoveEvent(this YourClass source)
{
return Observable.FromEventPattern<MouseEventArgs>(
h => source.MouseMove += h,
h => source.MouseMove -= h);
}
Basic Usage
Converting Events to Observables
using ReactiveMarbles.ObservableEvents;
public partial class MainViewModel : ReactiveObject
{
public MainViewModel()
{
// Subscribe to button click events
var button = new Button();
button.Events().Click
.Subscribe(_ => Console.WriteLine("Button clicked!"));
}
}
With Reactive Extensions Operators
The real power comes from composing with Rx operators:
// Throttle rapid clicks
button.Events().Click
.Throttle(TimeSpan.FromMilliseconds(500))
.Subscribe(_ => PerformAction());
// Take only the first 3 clicks
button.Events().Click
.Take(3)
.Subscribe(_ => Console.WriteLine("Click counted"));
// Filter based on event args
textBox.Events().TextChanged
.Select(e => e.Sender as TextBox)
.Where(tb => tb?.Text.Length > 3)
.Subscribe(tb => ValidateInput(tb.Text));
Platform-Specific Examples
WPF
using ReactiveMarbles.ObservableEvents;
public partial class MainWindow : ReactiveWindow<MainViewModel>
{
public MainWindow()
{
InitializeComponent();
this.WhenActivated(disposables =>
{
// Window events
this.Events().Loaded
.Subscribe(_ => Console.WriteLine("Window loaded"))
.DisposeWith(disposables);
this.Events().Closing
.Subscribe(e =>
{
var result = MessageBox.Show("Are you sure?", "Confirm", MessageBoxButton.YesNo);
e.EventArgs.Cancel = result == MessageBoxResult.No;
})
.DisposeWith(disposables);
// Mouse events with throttling
SearchTextBox.Events().KeyUp
.Throttle(TimeSpan.FromMilliseconds(300))
.Select(e => (e.Sender as TextBox)?.Text)
.Where(text => !string.IsNullOrWhiteSpace(text))
.Subscribe(text => ViewModel.Search(text))
.DisposeWith(disposables);
});
}
}
MAUI
using ReactiveMarbles.ObservableEvents;
public partial class MainPage : ReactiveContentPage<MainViewModel>
{
public MainPage()
{
InitializeComponent();
this.WhenActivated(disposables =>
{
// Entry text changes
SearchEntry.Events().TextChanged
.Throttle(TimeSpan.FromMilliseconds(500))
.Select(e => e.EventArgs.NewTextValue)
.Subscribe(text => ViewModel.SearchText = text)
.DisposeWith(disposables);
// Button taps with debounce
SubmitButton.Events().Clicked
.Throttle(TimeSpan.FromSeconds(1))
.Subscribe(_ => ViewModel.SubmitCommand.Execute().Subscribe())
.DisposeWith(disposables);
});
}
}
Avalonia
using ReactiveMarbles.ObservableEvents;
public partial class MainWindow : ReactiveWindow<MainViewModel>
{
public MainWindow()
{
InitializeComponent();
this.WhenActivated(disposables =>
{
// Pointer events
this.Events().PointerPressed
.Subscribe(e => Console.WriteLine($"Clicked at: {e.EventArgs.GetPosition(this)}"))
.DisposeWith(disposables);
// Text input with validation
UsernameTextBox.Events().TextChanged
.Select(e => e.EventArgs.Text)
.DistinctUntilChanged()
.Subscribe(text => ViewModel.Username = text)
.DisposeWith(disposables);
});
}
}
Blazor
using ReactiveMarbles.ObservableEvents;
public partial class CounterComponent : ReactiveComponentBase<CounterViewModel>
{
protected override void OnInitialized()
{
ViewModel = new CounterViewModel();
this.WhenActivated(disposables =>
{
// For Blazor, you typically use @onclick in markup
// But you can still use ObservableEvents for custom components
// Example with a custom component that has events
CustomComponent.Events().ValueChanged
.Subscribe(e => ViewModel.Value = e.EventArgs.NewValue)
.DisposeWith(disposables);
});
base.OnInitialized();
}
}
Advanced Patterns
Combining Multiple Events
// Wait for both events before proceeding
var loaded = window.Events().Loaded;
var dataReady = dataService.Events().DataLoaded;
loaded.Zip(dataReady, (l, d) => Unit.Default)
.Subscribe(_ => InitializeUI());
Event Sequences with State
// Track double-clicks
button.Events().Click
.Buffer(TimeSpan.FromMilliseconds(300))
.Where(clicks => clicks.Count == 2)
.Subscribe(_ => HandleDoubleClick());
Mouse Drag Pattern
var mouseDown = canvas.Events().MouseDown;
var mouseMove = canvas.Events().MouseMove;
var mouseUp = canvas.Events().MouseUp;
var drag = from down in mouseDown
from move in mouseMove.TakeUntil(mouseUp)
select move.EventArgs.GetPosition(canvas);
drag.Subscribe(pos => UpdateDragPosition(pos));
Keyboard Shortcuts
this.Events().KeyDown
.Where(e => e.EventArgs.Key == Key.S &&
(Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control)
.Subscribe(_ => SaveDocument());
Integration with ReactiveUI Bindings
ObservableEvents works seamlessly with ReactiveUI's binding system:
public partial class MainWindow : ReactiveWindow<MainViewModel>
{
public MainWindow()
{
InitializeComponent();
this.WhenActivated(disposables =>
{
// Convert event to property
SearchBox.Events().TextChanged
.Select(e => (e.Sender as TextBox)?.Text)
.BindTo(this, x => x.ViewModel.SearchText)
.DisposeWith(disposables);
// Use event with command
SaveButton.Events().Click
.InvokeCommand(ViewModel.SaveCommand)
.DisposeWith(disposables);
});
}
}
Performance Considerations
- Memory Management: Always use
DisposeWith(disposables)insideWhenActivatedto prevent memory leaks - Throttling: Use
ThrottleorDebouncefor high-frequency events like mouse moves or text changes - Unsubscription: The generator handles proper event unsubscription when you dispose the observable
Common Patterns
Form Validation
var usernameValid = UsernameTextBox.Events().TextChanged
.Select(e => e.EventArgs.Text)
.Select(text => !string.IsNullOrWhiteSpace(text) && text.Length >= 3);
var passwordValid = PasswordTextBox.Events().TextChanged
.Select(e => e.EventArgs.Text)
.Select(text => text?.Length >= 6);
usernameValid.CombineLatest(passwordValid, (u, p) => u && p)
.Subscribe(valid => SubmitButton.IsEnabled = valid);
Search with Cancellation
SearchBox.Events().TextChanged
.Throttle(TimeSpan.FromMilliseconds(500))
.Select(e => (e.Sender as TextBox)?.Text)
.DistinctUntilChanged()
.SelectMany(async text => await SearchAsync(text))
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(results => DisplayResults(results));
Focus Management
// Auto-focus next field
FirstNameTextBox.Events().KeyDown
.Where(e => e.EventArgs.Key == Key.Enter)
.Subscribe(_ => LastNameTextBox.Focus());
Configuration
ObservableEvents can be configured via MSBuild properties in your project file:
<PropertyGroup>
<!-- Generate events for specific assemblies only -->
<ObservableEventsAssemblies>MyCustomControls</ObservableEventsAssemblies>
<!-- Exclude specific types -->
<ObservableEventsExclude>ObsoleteControl</ObservableEventsExclude>
</PropertyGroup>
Troubleshooting
Events Not Generated
If you don't see generated events:
- Rebuild the project - Source generators run during compilation
- Check the assembly is referenced - ObservableEvents only generates for referenced assemblies
- Verify event visibility - Only public events are generated
- Update packages - Ensure you have the latest version
IDE Support
- Visual Studio 2022+: Full support with IntelliSense
- Visual Studio Code: Install C# Dev Kit for full support
- Rider: Supported in recent versions
Viewing Generated Code
To see generated code:
- In Solution Explorer, expand Dependencies ? Analyzers ? ReactiveMarbles.ObservableEvents.SourceGenerator
- Look for generated files under the generator node
Comparison with Manual Event Handling
Traditional Approach
public MainWindow()
{
InitializeComponent();
button.Click += Button_Click;
textBox.TextChanged += TextBox_TextChanged;
}
private void Button_Click(object sender, EventArgs e)
{
// Handle click
}
private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
{
// Handle text change
}
protected override void OnClosed(EventArgs e)
{
// Must manually unsubscribe
button.Click -= Button_Click;
textBox.TextChanged -= TextBox_TextChanged;
base.OnClosed(e);
}
ObservableEvents Approach
public MainWindow()
{
InitializeComponent();
this.WhenActivated(disposables =>
{
button.Events().Click
.Subscribe(_ => HandleClick())
.DisposeWith(disposables);
textBox.Events().TextChanged
.Throttle(TimeSpan.FromMilliseconds(300))
.Subscribe(e => HandleTextChange(e.EventArgs))
.DisposeWith(disposables);
// Automatic cleanup when disposed
});
}
Best Practices
- Always Dispose: Use
WhenActivatedandDisposeWithto ensure proper cleanup - Throttle High-Frequency Events: Use
ThrottleorDebouncefor events that fire frequently - Use Strong Typing: Leverage the typed
EventPattern<T>for better IntelliSense - Combine with Commands: Use
InvokeCommandto integrate with ReactiveCommand - Handle Errors: Use
CatchorOnErrorResumeNextfor resilient event handling
Additional Resources
- ObservableEvents GitHub Repository
- ReactiveUI Documentation
- Reactive Extensions Documentation
- Sample Applications