Table of Contents

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 readyINotifyPropertyChanged, INotifyDataErrorInfo
  • Property binding — Use PropertyState for two-way binding to record properties

💡 Tip: Use [BindableFeed] on State<T> properties for automatic PropertyChanged forwarding. See Analyzers for best practices.


Lifecycle

Load → Edit → Validate → Save/Revert
  │                         │
  └── IsDirty tracks ───────┘
  1. Load — Data loaded from source
  2. EditUpdate() modifies value, sets IsDirty = true
  3. Validate — Automatic or manual validation
  4. SaveSaveAsync() persists, clears dirty flag
  5. RevertRevertAsync() 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:

  • InvalidOperationException if 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