Skip to main content

Getting Started

Installation​

dotnet add package ZeroAlloc.Notify

The source generator is bundled into the package, so this single PackageReference is all you need.

The standalone ZeroAlloc.Notify.Generator package is still published for backwards compatibility with existing direct PackageReferences, but new consumers should reference only ZeroAlloc.Notify.

Your First Observable Property​

1. Define a ViewModel​

using ZeroAlloc.Notify;

[NotifyPropertyChangedAsync]
public partial class UserViewModel
{
[ObservableProperty]
private string _name = "Anonymous";
}

2. Subscribe to Changes​

var vm = new UserViewModel();

// Subscribe to property changes
vm.PropertyChangedAsync += async (args, ct) =>
{
Console.WriteLine($"{args.PropertyName} changed to {args.NewValue}");
await Task.Delay(100, ct);
};

// Setting the property triggers handlers
await vm.SetNameAsync("Alice");
// Output: Name changed to Alice

Observable Collections​

[NotifyCollectionChangedAsync]
public partial class TodoListViewModel
{
[ObservableProperty]
private ObservableCollection<string> _todos = new();
}

var vm = new TodoListViewModel();
vm.CollectionChangedAsync += async (args, ct) =>
{
Console.WriteLine($"Collection action: {args.Action}");
};

await vm.SetTodosAsync(new ObservableCollection<string> { "Task 1" });

Data Validation​

using System.Collections;
using System.Collections.Concurrent;
using ZeroAlloc.Notify;

[NotifyDataErrorInfoAsync]
public partial class FormViewModel : INotifyDataErrorInfoAsync
{
private readonly ConcurrentDictionary<string, List<string>> _errors = new();

[ObservableProperty]
private string _email = "";

public bool HasErrors => !_errors.IsEmpty;

public IEnumerable GetErrors(string? propertyName)
=> propertyName is null
? _errors.Values.SelectMany(e => e)
: _errors.TryGetValue(propertyName, out var list) ? list : Enumerable.Empty<string>();

private async ValueTask ValidateEmailAsync(string value, CancellationToken ct)
{
_errors.Remove(nameof(Email), out _);
if (string.IsNullOrEmpty(value))
_errors[nameof(Email)] = new List<string> { "Email is required" };
else if (!value.Contains('@'))
_errors[nameof(Email)] = new List<string> { "Invalid email format" };
await RaiseErrorsChangedAsync(nameof(Email), ct);
}
}

var vm = new FormViewModel();
vm.PropertyChangedAsync += async (args, ct) =>
{
if (args.PropertyName == nameof(vm.Email))
await vm.ValidateEmailAsync((string)args.NewValue!, ct);
};

await vm.SetEmailAsync("not-an-email");

var hasErrors = vm.HasErrors;
var errors = vm.GetErrors(nameof(vm.Email));

Key Concepts​

Async All the Way​

ZeroAlloc.Notify is async-first. Even if your handlers are synchronous, the API uses ValueTask to support both blocking and non-blocking handlers without allocation.

// Synchronous handler — completes immediately
vm.PropertyChangedAsync += async (args, ct) =>
{
Console.WriteLine("Handler ran");
return; // No Task allocation needed
};

// Asynchronous handler — suspends and resumes
vm.PropertyChangedAsync += async (args, ct) =>
{
await Task.Delay(1000, ct);
Console.WriteLine("Handler resumed");
};

Sequential vs Parallel​

By default, handlers run in parallel (fire-and-forget):

[NotifyPropertyChangedAsync]
[InvokeSequentially] // Enforce sequential handler execution for this class
public partial class MyViewModel
{
[ObservableProperty]
private string _name = "";
}

Source Generation​

The source generator automatically creates:

  • Setter methods (SetNameAsync, SetEmailAsync)
  • Event raiser methods
  • Implementation of INotifyPropertyChangedAsync, INotifyCollectionChangedAsync, etc.

You apply the attributes; the generator creates the rest.

Nested and Generic Classes​

A class nested in another type, or a generic class, gets its members on the class itself. The generated file reopens every containing type, so each containing type has to be partial:

public static partial class Orders
{
[NotifyPropertyChangedAsync]
public partial class Entry<T>
{
[ObservableProperty]
private T? _value;
}
}

await new Orders.Entry<int>().SetValueAsync(7);

A nested class in a containing type that is not partial gets the warning ZAN001 and nothing is generated for it.

Next Steps​