State<T> Class
Editable data container with validation, dirty tracking, and save/revert support.
Namespace: DevBitsLab.Feeds
Assembly: DevBitsLab.Feeds.dll
public sealed class State<T> : IFeed<T>, INotifyPropertyChanged, INotifyDataErrorInfo, IDisposable
Overview
State<T> extends feed capabilities with:
- Local editing — Modify without immediate persistence
- Dirty tracking — Know when values changed
- Validation — Property-level error support
- Save/Revert — Persist or discard changes
- MVVM ready —
INotifyPropertyChanged,INotifyDataErrorInfo - Property binding — Use
PropertyStatefor two-way binding to record properties
💡 Tip: Use
[BindableFeed]onState<T>properties for automatic PropertyChanged forwarding. See Analyzers for best practices.
Lifecycle
Load → Edit → Validate → Save/Revert
│ │
└── IsDirty tracks ───────┘
- Load — Data loaded from source
- Edit —
Update()modifies value, setsIsDirty = true - Validate — Automatic or manual validation
- Save —
SaveAsync()persists, clears dirty flag - Revert —
RevertAsync()discards changes
Creating States
Create (Auto-loading)
var customerState = State<Customer>.Create(
loadFunc: async ct => await api.GetCustomerAsync(id, ct),
saveFunc: async (c, ct) => await api.UpdateCustomerAsync(c, ct),
validateFunc: c => ValidateCustomer(c));
// customerState.IsLoading = true initially
CreateDeferred
var state = State<ExpensiveData>.CreateDeferred(
loadFunc: LoadExpensiveDataAsync,
saveFunc: SaveDataAsync);
// Later
await state.RefreshAsync();
FromValue
var settingsState = State<Settings>.FromValue(
new Settings { Theme = "Light" },
saveFunc: SaveSettingsAsync,
validateFunc: ValidateSettings);
FromFeed (Real-time Sync)
var priceFeed = Feed<StockPrice>.FromAsyncEnumerable(
stockService.StreamPricesAsync(symbol));
var priceState = State<StockPrice>.FromFeed(
priceFeed,
saveFunc: async (p, ct) => await SaveAlertAsync(p.AlertPrice, ct));
// Auto-updates from feed when not dirty
Properties
Value Properties
| Property | Type | Description |
|---|---|---|
Value |
T? |
Current value (read-only) |
CurrentValue |
T? |
Current value (get/set for binding) |
Feed |
Feed<T> |
Underlying feed |
State Properties
| Property | Type | Description |
|---|---|---|
FeedState |
FeedState |
Current state |
IsLoading |
bool |
Loading in progress |
HasValue |
bool |
Value loaded |
HasError |
bool |
Load error occurred |
Error |
Exception? |
Error details |
Edit Properties
| Property | Type | Description |
|---|---|---|
IsDirty |
bool |
Unsaved changes exist |
IsSaving |
bool |
Save in progress |
HasErrors |
bool |
Validation errors exist |
ValidationErrors |
IReadOnlyList<string> |
All error messages |
ValidationResult |
ValidationResult |
Full validation details |
Events
| Event | Description |
|---|---|
StateChanged |
Feed state transitions |
DirtyChanged |
IsDirty changed |
SaveCompleted |
Save finished (success or error) |
PropertyChanged |
Any property changed (MVVM) |
ErrorsChanged |
Validation errors changed |
Methods
Update
Modify value locally (marks dirty).
// Direct value
state.Update(newValue);
// Using mutator
state.Update(current => current with { Name = "New Name" });
SaveAsync
Persist changes using save function.
if (state.IsDirty && !state.HasErrors) {
await state.SaveAsync();
}
Throws:
InvalidOperationExceptionif no save function, no value, or has errors
RevertAsync
Discard changes, reload from source.
await state.RevertAsync();
// IsDirty = false, value restored
RefreshAsync
Reload from load function.
await state.RefreshAsync();
Reset
Set value without marking dirty.
state.Reset(defaultValue);
MarkDirty / MarkClean
Manual dirty flag control.
state.MarkDirty(); // Force dirty
state.MarkClean(); // Clear dirty
Validation
Validation Function
var state = State<Product>.Create(
loadFunc: LoadProductAsync,
saveFunc: SaveProductAsync,
validateFunc: product => {
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(product?.Name))
result.AddError(nameof(Product.Name), "Name is required");
if (product?.Price < 0)
result.AddError(nameof(Product.Price), "Price cannot be negative");
return result;
});
Checking Errors
if (state.HasErrors) {
foreach (var error in state.ValidationErrors) {
Console.WriteLine(error);
}
// Property-specific errors
var nameErrors = state.ValidationResult.GetErrors("Name");
}
INotifyDataErrorInfo Support
// XAML binding automatically shows errors
// <TextBox Text="{Binding State.CurrentValue.Name}"/>
// Validation errors display via ErrorTemplate
IEnumerable errors = state.GetErrors("Name");
MVVM Integration
Two-Way Binding
<!-- WPF/MAUI -->
<TextBox Text="{Binding CustomerState.CurrentValue.Name, Mode=TwoWay}"/>
<Button Command="{binding SaveCommand}"
IsEnabled="{Binding CustomerState.IsDirty}"/>
With Commands
var state = State<Customer>.Create(
LoadCustomerAsync,
SaveCustomerAsync,
ValidateCustomer,
saveCommand: SaveCommand, // Notified on dirty/error change
revertCommand: RevertCommand); // Notified on dirty change
Common Patterns
Settings Editor
public class SettingsViewModel : IDisposable
{
public State<Settings> Settings { get; }
public SettingsViewModel()
{
Settings = State<Settings>.Create(
loadFunc: LoadSettingsAsync,
saveFunc: SaveSettingsAsync,
validateFunc: ValidateSettings);
Settings.DirtyChanged += OnDirtyChanged;
Settings.SaveCompleted += OnSaveCompleted;
}
public async Task SaveAsync()
{
if (Settings.IsDirty && !Settings.HasErrors)
{
await Settings.SaveAsync();
}
}
public async Task CancelAsync()
{
if (Settings.IsDirty)
{
await Settings.RevertAsync();
}
}
private void OnDirtyChanged(object? sender, bool isDirty)
{
// Update UI or command states
SaveCommand.RaiseCanExecuteChanged();
RevertCommand.RaiseCanExecuteChanged();
}
private void OnSaveCompleted(object? sender, SaveCompletedEventArgs e)
{
if (e.Success)
ShowNotification("Settings saved successfully!");
else
ShowError($"Save failed: {e.Error!.Message}");
}
private static Task<Settings> LoadSettingsAsync(CancellationToken ct)
=> settingsService.LoadAsync(ct);
private static Task SaveSettingsAsync(Settings settings, CancellationToken ct)
=> settingsService.SaveAsync(settings, ct);
private static ValidationResult ValidateSettings(Settings? settings)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(settings?.Theme))
result.AddError(nameof(Settings.Theme), "Theme is required");
return result;
}
public void Dispose()
{
Settings.DirtyChanged -= OnDirtyChanged;
Settings.SaveCompleted -= OnSaveCompleted;
Settings.Dispose();
}
}
Form with Unsaved Changes Warning
public partial class EditPage : Page
{
private readonly State<Document> _state;
public EditPage(int documentId)
{
_state = State<Document>.Create(
loadFunc: ct => documentService.LoadAsync(documentId, ct),
saveFunc: (doc, ct) => documentService.SaveAsync(doc, ct));
}
protected override async void OnNavigatingFrom(NavigatingCancelEventArgs e)
{
if (_state.IsDirty)
{
e.Cancel = true;
var result = await ShowConfirmationDialog(
"Unsaved Changes",
"Do you want to save your changes?");
switch (result)
{
case ConfirmResult.Save:
await _state.SaveAsync();
NavigateAway();
break;
case ConfirmResult.Discard:
await _state.RevertAsync();
NavigateAway();
break;
case ConfirmResult.Cancel:
// Stay on page - do nothing
break;
}
}
}
private void NavigateAway()
{
// Resume navigation after save/discard
Frame.Navigate(typeof(HomePage));
}
private Task<ConfirmResult> ShowConfirmationDialog(string title, string message)
{
// Platform-specific dialog implementation
throw new NotImplementedException();
}
}
public enum ConfirmResult { Save, Discard, Cancel }
Real-time Data with Local Edits
var stockFeed = Feed<Stock>.FromAsyncEnumerable(
stockService.StreamAsync(symbol));
var stockState = State<Stock>.FromFeed(stockFeed);
// State updates from feed when not dirty
// User edits preserved, feed updates ignored while dirty
stockState.Update(s => s with { AlertPrice = 150m });
// Now dirty - feed updates won't overwrite
await stockState.SaveAsync();
// Clean - feed updates resume
Performance
| Operation | Mean | Allocations |
|---|---|---|
MarkDirty |
~0.08 ns | 0 |
Access IsDirty |
~0.24 ns | 0 |
Access Value |
~0.89 ns | 0 |
Update (same) |
~1.17 ns | 0 |
GetErrors |
~22 ns | 64 B |
FromValue |
~130 ns | 736 B |
Reset |
~2.7 µs | 0 |
Update (new) |
~3.8 µs | 0 |
Update + validate |
~8.6 µs | 0 |
Thread Safety
State<T> is thread-safe for all operations:
// Safe from any thread
await Task.Run(() => state.Update(newValue));
await Task.Run(() => state.SaveAsync());
// Concurrent access is safe
await Task.WhenAll(
Task.Run(() => Console.WriteLine(state.Value)),
Task.Run(() => state.Update(value1)),
Task.Run(() => state.MarkDirty())
);
Thread Safety Guarantees
| Operation | Thread-Safe |
|---|---|
Property access (Value, IsDirty, etc.) |
✅ Yes |
Update |
✅ Yes |
SaveAsync |
✅ Yes |
RevertAsync |
✅ Yes |
RefreshAsync |
✅ Yes |
Reset |
✅ Yes |
MarkDirty / MarkClean |
✅ Yes |
Dispose |
✅ Yes |
| Event subscription | ✅ Yes |
Event Handler Threading
Important: Event handlers (
StateChanged,DirtyChanged,SaveCompleted,PropertyChanged,ErrorsChanged) are invoked on the thread that triggered the change. Marshal to the UI thread when updating UI:
state.PropertyChanged += (s, e) => {
// WPF
Dispatcher.Invoke(() => UpdateUI());
// WinUI / MAUI
DispatcherQueue.TryEnqueue(() => UpdateUI());
};
Concurrent Save Operations
Only one SaveAsync operation runs at a time. Subsequent calls wait for the current save to complete:
// Second save waits for first to complete
var task1 = state.SaveAsync();
var task2 = state.SaveAsync(); // Queued, not concurrent
await Task.WhenAll(task1, task2);
SaveCompletedEventArgs
public sealed class SaveCompletedEventArgs : EventArgs {
public Exception? Error { get; }
public bool Success => Error is null;
}
Two-Way Binding to Record Properties
When using records (immutable types), you cannot directly two-way bind to State.Value.PropertyName. Use PropertyState<TParent, TProperty> to create bindable property wrappers:
public record Person(string Name, int Age, string Email);
public partial class PersonViewModel : ObservableObject
{
private readonly State<Person> _personState;
// Derived property states for two-way binding
public PropertyState<Person, string> Name { get; }
public PropertyState<Person, int> Age { get; }
public PropertyState<Person, string> Email { get; }
public PersonViewModel()
{
_personState = State<Person>.FromValue(new Person("John", 30, "john@example.com"));
Name = _personState.Property(p => p.Name, (p, v) => p with { Name = v });
Age = _personState.Property(p => p.Age, (p, v) => p with { Age = v });
Email = _personState.Property(p => p.Email, (p, v) => p with { Email = v });
}
}
XAML:
<TextBox Text="{x:Bind ViewModel.Name.Value, Mode=TwoWay}" />
<TextBox Text="{x:Bind ViewModel.Email.Value, Mode=TwoWay}" />
See PropertyState<TParent, TProperty> for detailed use cases.
See Also
Feed<T>— Base feed classPropertyState<TParent, TProperty>— Two-way binding for record propertiesValidationResult— Validation detailsIFeed<T>— Feed interface- Analyzers — Code analysis rules for best practices