PropertyState<TParent, TProperty> Class
Provides two-way data binding to individual properties of a record or class stored in a State<T>.
Namespace: DevBitsLab.Feeds
Assembly: DevBitsLab.Feeds.dll
public sealed class PropertyState<TParent, TProperty> : INotifyPropertyChanged, IDisposable
where TParent : class
Overview
PropertyState<TParent, TProperty> solves a common MVVM challenge: two-way binding to individual properties of an immutable record.
When using State<Person> with a record type, you cannot directly two-way bind to State.Value.Name because records are immutable. PropertyState bridges this gap by:
- Providing a
Valueproperty that can be two-way bound - Automatically creating a new record instance using
withexpressions when the value changes - Propagating changes back to the parent
State<T> - Forwarding
IsDirtyand validation state from the parent
Use Cases
1. Form Editing with Records
Problem: You have a Person record and want to edit individual fields in a form.
public record Person(string FirstName, string LastName, string Email, int Age);
public partial class PersonEditorViewModel : ObservableObject, IDisposable
{
private readonly State<Person> _personState;
public PropertyState<Person, string> FirstName { get; }
public PropertyState<Person, string> LastName { get; }
public PropertyState<Person, string> Email { get; }
public PropertyState<Person, int> Age { get; }
public PersonEditorViewModel(Person person)
{
_personState = State<Person>.FromValue(person, SavePersonAsync);
FirstName = _personState.Property(p => p.FirstName, (p, v) => p with { FirstName = v });
LastName = _personState.Property(p => p.LastName, (p, v) => p with { LastName = v });
Email = _personState.Property(p => p.Email, (p, v) => p with { Email = v });
Age = _personState.Property(p => p.Age, (p, v) => p with { Age = v });
}
public bool IsDirty => _personState.IsDirty;
public async Task SaveAsync() => await _personState.SaveAsync();
public void Dispose()
{
FirstName.Dispose();
LastName.Dispose();
Email.Dispose();
Age.Dispose();
}
private Task SavePersonAsync(Person person, CancellationToken ct) =>
personService.UpdateAsync(person, ct);
}
XAML:
<StackPanel>
<TextBox Header="First Name"
Text="{x:Bind ViewModel.FirstName.Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Header="Last Name"
Text="{x:Bind ViewModel.LastName.Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Header="Email"
Text="{x:Bind ViewModel.Email.Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<NumberBox Header="Age"
Value="{x:Bind ViewModel.Age.Value, Mode=TwoWay}" />
<Button Content="Save"
Command="{x:Bind ViewModel.SaveCommand}"
IsEnabled="{x:Bind ViewModel.IsDirty, Mode=OneWay}" />
</StackPanel>
2. Settings Editor with Nested Records
Problem: You have nested configuration records and need to edit properties at different levels.
public record AppSettings(
ThemeSettings Theme,
NotificationSettings Notifications,
string Language);
public record ThemeSettings(string Name, bool IsDarkMode, int FontSize);
public record NotificationSettings(bool Enabled, bool Sound, bool Vibration);
public partial class SettingsViewModel : ObservableObject, IDisposable
{
private readonly State<AppSettings> _settingsState;
// Direct properties
public PropertyState<AppSettings, string> Language { get; }
// Nested theme properties - use lambda composition
public PropertyState<AppSettings, string> ThemeName { get; }
public PropertyState<AppSettings, bool> IsDarkMode { get; }
public PropertyState<AppSettings, int> FontSize { get; }
// Nested notification properties
public PropertyState<AppSettings, bool> NotificationsEnabled { get; }
public PropertyState<AppSettings, bool> SoundEnabled { get; }
public SettingsViewModel()
{
_settingsState = State<AppSettings>.Create(
LoadSettingsAsync,
SaveSettingsAsync);
Language = _settingsState.Property(
s => s.Language,
(s, v) => s with { Language = v });
// Nested properties require composing the with expressions
ThemeName = _settingsState.Property(
s => s.Theme.Name,
(s, v) => s with { Theme = s.Theme with { Name = v } });
IsDarkMode = _settingsState.Property(
s => s.Theme.IsDarkMode,
(s, v) => s with { Theme = s.Theme with { IsDarkMode = v } });
FontSize = _settingsState.Property(
s => s.Theme.FontSize,
(s, v) => s with { Theme = s.Theme with { FontSize = v } });
NotificationsEnabled = _settingsState.Property(
s => s.Notifications.Enabled,
(s, v) => s with { Notifications = s.Notifications with { Enabled = v } });
SoundEnabled = _settingsState.Property(
s => s.Notifications.Sound,
(s, v) => s with { Notifications = s.Notifications with { Sound = v } });
}
// ... Dispose implementation
}
3. Master-Detail with Shared Dirty Tracking
Problem: You have a list of items and a detail editor, and you want to track if any edits have been made.
public partial class OrderViewModel : ObservableObject
{
private readonly State<Order> _orderState;
public PropertyState<Order, string> CustomerName { get; }
public PropertyState<Order, string> ShippingAddress { get; }
public PropertyState<Order, DateTime> DeliveryDate { get; }
// All properties share the same dirty state
public bool HasUnsavedChanges => _orderState.IsDirty;
public OrderViewModel(Order order)
{
_orderState = State<Order>.FromValue(order, SaveOrderAsync);
CustomerName = _orderState.Property(o => o.CustomerName, (o, v) => o with { CustomerName = v });
ShippingAddress = _orderState.Property(o => o.ShippingAddress, (o, v) => o with { ShippingAddress = v });
DeliveryDate = _orderState.Property(o => o.DeliveryDate, (o, v) => o with { DeliveryDate = v });
// Subscribe to dirty changes
_orderState.DirtyChanged += (s, isDirty) => OnPropertyChanged(nameof(HasUnsavedChanges));
}
}
4. Wizard with Multi-Step Form
Problem: A multi-step wizard where each step edits different properties of the same record.
public record RegistrationData(
string Email,
string Password,
string FirstName,
string LastName,
string Phone,
Address Address);
// Step 1: Account details
public class AccountStepViewModel
{
public PropertyState<RegistrationData, string> Email { get; }
public PropertyState<RegistrationData, string> Password { get; }
public AccountStepViewModel(State<RegistrationData> state)
{
Email = state.Property(r => r.Email, (r, v) => r with { Email = v });
Password = state.Property(r => r.Password, (r, v) => r with { Password = v });
}
}
// Step 2: Personal info
public class PersonalStepViewModel
{
public PropertyState<RegistrationData, string> FirstName { get; }
public PropertyState<RegistrationData, string> LastName { get; }
public PropertyState<RegistrationData, string> Phone { get; }
public PersonalStepViewModel(State<RegistrationData> state)
{
FirstName = state.Property(r => r.FirstName, (r, v) => r with { FirstName = v });
LastName = state.Property(r => r.LastName, (r, v) => r with { LastName = v });
Phone = state.Property(r => r.Phone, (r, v) => r with { Phone = v });
}
}
// Main wizard - all steps share the same State<RegistrationData>
public class RegistrationWizardViewModel
{
private readonly State<RegistrationData> _registrationState;
public AccountStepViewModel AccountStep { get; }
public PersonalStepViewModel PersonalStep { get; }
public RegistrationWizardViewModel()
{
_registrationState = State<RegistrationData>.FromValue(
new RegistrationData("", "", "", "", "", new Address()),
SubmitRegistrationAsync);
AccountStep = new AccountStepViewModel(_registrationState);
PersonalStep = new PersonalStepViewModel(_registrationState);
}
}
Creating PropertyState
Use the Property extension method on State<T>:
var personState = State<Person>.FromValue(new Person("John", 30));
var nameProperty = personState.Property(
getter: p => p.Name, // How to get the property
setter: (p, v) => p with { Name = v } // How to create new parent with updated property
);
Properties
| Property | Type | Description |
|---|---|---|
Value |
TProperty? |
Gets or sets the property value. Setting updates the parent. |
HasValue |
bool |
Whether the parent state has a value. |
IsDirty |
bool |
Whether the parent state has unsaved changes. |
Errors |
IReadOnlyList<string> |
Validation errors from the parent. |
Events
| Event | Description |
|---|---|
PropertyChanged |
Raised when Value, IsDirty, or HasValue changes. |
Thread Safety
PropertyState<TParent, TProperty> inherits thread safety from its parent State<T>. All operations are thread-safe.
Best Practices
1. Always Dispose PropertyStates
public class MyViewModel : IDisposable
{
public PropertyState<Person, string> Name { get; }
public void Dispose()
{
Name.Dispose();
}
}
2. Use Records for Immutable Updates
// Good: Records with `with` expressions
public record Person(string Name, int Age);
var name = state.Property(p => p.Name, (p, v) => p with { Name = v });
// Works but verbose: Classes require manual cloning
public class Person { public string Name { get; set; } }
var name = state.Property(
p => p.Name,
(p, v) => new Person { Name = v, Age = p.Age, /* ... */ });
3. Share State Across ViewModels
// Parent ViewModel owns the State
public class ParentViewModel
{
public State<AppData> AppState { get; }
}
// Child ViewModels create PropertyStates from shared State
public class ChildViewModel
{
public PropertyState<AppData, string> Setting { get; }
public ChildViewModel(State<AppData> sharedState)
{
Setting = sharedState.Property(s => s.Setting, (s, v) => s with { Setting = v });
}
}
See Also
- State<T> Class — Parent state container
- Feed<T> Class — Read-only reactive feed
- Analyzers — Code analysis for best practices