Datatypes on steroids with tracking/events when values change. Used to enhance performance for computationally intensive programs and/or to minimize saves to a database.
$ dotnet add package TrackedValChange‑tracking value wrappers for .NET applications
TrackedValues is a lightweight library that provides change‑tracking wrappers for standard .NET value types and strings. It includes a generic base class (TrackedValue<T>), specialized implementations (e.g., TrackedDouble, TrackedInt, TrackedString, …), and an aggregator (TrackedValues) for managing groups of tracked values as a unit.
Typical use cases include data‑entry screens, workflow tools, simulation/calculation engines, and configuration editors—anywhere you want to detect when values changed, efficiently update dependent logic/UI, and avoid unnecessary saves.
Saved() (baseline reset) and IsClean() (workflow reset)Tracked wrappers for common .NET primitives:
TrackedInt, TrackedDouble, TrackedFloat, TrackedDecimalTrackedLong, TrackedULongTrackedBool, TrackedStringTrackedValue<T> (generic base for your custom types)TrackedValues lets you treat multiple tracked values as a single unit:
IsDirty if any child is dirtyUnsaved if any child is unsavedSaved() and IsClean() across childrenShell
dotnet add package TrackedValues
PowerShell
Install-Package TrackedValues
IsDirty and UnsavedIsDirty — workflow signal
IsClean().Saved() does not change IsDirty.Unsaved — persistence signal
Unsaved == (Initialized && !Equals(Value, InitialValue)).InitialValue = Value), so Unsaved = false.Unsaved = true. If it returns to the baseline, Unsaved = false again.Saved() updates the baseline to the current value (making Unsaved = false).These flags serve different purposes and are intentionally not coupled.
“Set on any value change; cleared only by
IsClean().”
| Action | IsDirty after action |
|---|---|
| First explicit set (e.g., load from DB into tracker) | true |
| Change to a new value | true |
| Change back to baseline | true |
Call IsClean() | false |
Call Saved() | no change |
Notes:
IsDirty is about whether dependents need to react. Even if a user changes a value and then undoes it back to the baseline, dependents still need to know to re‑render/recalculate—hence IsDirty remains true until you call IsClean().“True if current value does not equal the baseline.”
| Current Value | InitialValue (Baseline) | Unsaved |
|---|---|---|
| 5 | 5 | false |
| 12 | 5 | true |
| 5 | 5 | false |
12 (then Saved()) | 12 | false |
Notes:
Saved() aligns InitialValue with Value, making Unsaved = false.Saved() does not modify IsDirty.C#
TrackedDouble amount = 5; // first explicit set
// Baseline captured from the provided value
Console.WriteLine(amount.Value); // 5
Console.WriteLine(amount.InitialValue); // 5
// Signals:
Console.WriteLine(amount.IsDirty); // true -> dependents should update once
Console.WriteLine(amount.Unsaved); // false -> no persistence needed
Show more lines
Typical pattern:
C#
if (amount.IsDirty)
{
UpdateUIAndCalculations();
amount.IsClean(); // acknowledge dependents updated
}
if (amount.Unsaved)
{
SaveToDatabase(amount.Value);
amount.Saved(); // set new baseline
}
Show more lines
C#
var weight = new TrackedDouble();
weight.Value = 150.0; // first set -> baseline 150, IsDirty=true, Unsaved=false
weight.Value = 155.0; // changed
// IsDirty = true, Unsaved = true
weight.Value = 150.0; // undone to baseline
// IsDirty = true (still needs dependent updates), Unsaved = false
// Acknowledge dependent updates
weight.IsClean(); // IsDirty = false
// Optional: if you decide 150 is the committed value going forward
weight.Saved(); // Unsaved already false; Saved just resets baseline (150)
Show more lines
a { text-decoration: none; color: #464feb; } tr th, tr td { border: 1px solid #e6e6e6; } tr th { background-color: #f5f5f5; }Tracked types such as TrackedDouble, TrackedInt, and TrackedDecimal support implicit conversions and operator overloads, allowing you to use them almost exactly like their corresponding primitive .NET types.
This enables clean, natural syntax:
C#
// Declare and initialize without using "new"
TrackedDouble x = 3.452;
// Use like a double
x += 5; // arithmetic
x *= 2; // more arithmetic
x--; // unary operators
x++; // increment
double d = x; // implicit conversion back to double
// Fully supports comparisons
if (x > 10)
{
Console.WriteLine("x is greater than 10");
}
// Still tracks state changes
Console.WriteLine(x.IsDirty); // true after any change
Console.WriteLine(x.Unsaved); // baseline comparison preserved
Each tracked type:
5 to TrackedDouble automatically constructs and initializes it).TrackedDouble to Math.Sqrt, or assign it to a double).+, -, *, /, %, unary +/-, ++, --, and comparisons).This gives tracked types the familiarity of primitives while providing:
IsDirty) — workflow update signalUnsaved) — persistence signalSaved() and IsClean()TrackedValuesC#
var all = new TrackedValues();
TrackedDouble pressure = 900.0;
var temperature = new TrackedDouble(72.0);
all.Add(pressure);
all.Add(temperature);
pressure.Value = 905.0;
// Aggregate signals
Console.WriteLine(all.IsDirty); // true (any child dirty)
Console.WriteLine(all.Unsaved); // true (pressure differs from its baseline)
// Bulk workflow reset (does not affect baselines)
all.IsClean();
Console.WriteLine(all.IsDirty); // false
Console.WriteLine(all.Unsaved); // true
// Bulk persistence commit (affects baselines only)
all.Saved();
Console.WriteLine(all.Unsaved); // false
a { text-decoration: none; color: #464feb; } tr th, tr td { border: 1px solid #e6e6e6; } tr th { background-color: #f5f5f5; }Tracked types behave like their primitive counterparts, so you can store them in arrays, lists, or any collection. Each element tracks its own state, and when paired with a TrackedValues aggregator, any change to any element marks the overall collection as dirty/unsaved.
C#
// Create an array of TrackedInt
TrackedInt[] scores = new TrackedInt[]
{
new TrackedInt(10),
new TrackedInt(20),
new TrackedInt(30)
};
// Wrap the array in a TrackedValues aggregator
var tracked = new TrackedValues();
foreach (var s in scores)
tracked.Add(s);
// All states start clean and saved
Console.WriteLine(tracked.IsDirty); // false
Console.WriteLine(tracked.Unsaved); // false
// Change one element in the array
scores[1].Value += 5; // modifies the second value from 20 → 25
// The element itself reflects its state
Console.WriteLine(scores[1].Value); // 25
Console.WriteLine(scores[1].IsDirty); // true
Console.WriteLine(scores[1].Unsaved); // true
// And the entire tracked set becomes dirty/unsaved
Console.WriteLine(tracked.IsDirty); // true
Console.WriteLine(tracked.Unsaved); // true
// Workflow operations still apply normally
tracked.IsClean(); // clears dirty state for all children
Console.WriteLine(tracked.IsDirty); // false (dependencies updated)
Console.WriteLine(tracked.Unsaved); // true (value still differs from baseline)
// Commit all values as the new baseline
tracked.Saved();
Console.WriteLine(tracked.Unsaved); // false
Show more lines
Each array element is its own TrackedInt instance, each with:
ValueInitialValueIsDirtyUnsavedValueChanged eventThe TrackedValues aggregator monitors them collectively:
IsDirty is true if any element has changedUnsaved is true if any element differs from its baselineIsClean() resets only workflow‑dirty stateSaved() resets baselines for all itemsThis pattern allows you to treat entire groups of values as a single logical unit while still maintaining per‑field tracking.
a { text-decoration: none; color: #464feb; } tr th, tr td { border: 1px solid #e6e6e6; } tr th { background-color: #f5f5f5; }Tracked types (TrackedInt, TrackedDouble, etc.) behave like their underlying primitives, so they can appear in arrays, lists, and even multi‑dimensional structures. Each tracked value maintains its own IsDirty and Unsaved state, and they integrate naturally with LINQ and the TrackedValues aggregator.
C#
var list = new List
{
new TrackedInt(10),
new TrackedInt(20),
new TrackedInt(30)
};
// Wrap them in an aggregator
var tracked = new TrackedValues();
foreach (var item in list)
tracked.Add(item);
// No dirty or unsaved changes yet
Console.WriteLine(tracked.IsDirty); // false
Console.WriteLine(tracked.Unsaved); // false
// Modify one element
list[0].Value += 5; // 10 -> 15
Console.WriteLine(list[0].IsDirty); // true
Console.WriteLine(list[0].Unsaved); // true
// The whole tracked set is now dirty/unsaved
Console.WriteLine(tracked.IsDirty); // true
Console.WriteLine(tracked.Unsaved); // true
Show more lines
Each TrackedInt tracks its own:
TrackedValues detects changes across all items.
Trees of tracked values work seamlessly with LINQ.
C#
// List of tracked ints
var readings = new List
{
new TrackedInt(5),
new TrackedInt(6),
new TrackedInt(7)
};
// Change two
readings[1].Value = 9;
readings[2].Value = 20;
var dirtyItems = readings.Where(r => r.IsDirty).ToList();
var unsavedItems = readings.Where(r => r.Unsaved).ToList();
Console.WriteLine(dirtyItems.Count); // 2
Console.WriteLine(unsavedItems.Count); // 2
Common uses:
Tracked values can also sit inside multi-dimensional arrays.
C#
var grid = new TrackedInt[,]
{
{ new TrackedInt(1), new TrackedInt(2) },
{ new TrackedInt(3), new TrackedInt(4) }
};
// Wrap all elements in aggregator
var tracked = new TrackedValues();
foreach (var item in grid)
tracked.Add(item);
// Update a cell
grid[1, 0].Value = 10; // 3 -> 10
Console.WriteLine(grid[1, 0].IsDirty); // true
Console.WriteLine(grid[1, 0].Unsaved); // true
// Aggregator reflects this
Console.WriteLine(tracked.IsDirty); // true
Console.WriteLine(tracked.Unsaved); // true
Code block expanded
This is useful for:
Imagine you have a UI (WinForms, WPF, MAUI, or Blazor) showing a list of scores or sensor readings.
C#
public class Scoreboard
{
public TrackedValues Tracked { get; } = new TrackedValues();
public List Scores { get; } = new List();
public Scoreboard(IEnumerable initialScores)
{
foreach (var s in initialScores)
{
var t = new TrackedInt(s);
Scores.Add(t);
Tracked.Add(t);
}
}
}
C#
var model = new Scoreboard(new[] { 100, 120, 90 });
// user edits score #1
model.Scores[0].Value = 110;
// UI checks:
if (model.Tracked.IsDirty)
view.RefreshDependentCharts(); // only if needed
if (model.Tracked.Unsaved)
saveButton.Enabled = true;
Show more lines
C#
model.Tracked.IsClean(); // dependent calculations updated
Show more lines
C#
SaveAllToDatabase(model.Scores.Select(s => s.Value));
model.Tracked.Saved(); // baseline updated
``
Show more lines
| Action | Score | Baseline | IsDirty | Unsaved |
|---|---|---|---|---|
| Model creation | 100 | 100 | true | false |
| User edits value → 110 | 110 | 100 | true | true |
| UI updates executed → IsClean() | 110 | 100 | false | true |
| User saves → Saved() | 110 | 110 | false | false |
This clearly demonstrates how the two state flags serve different purposes:
TrackedValue<T>T Value — current valueT InitialValue — baseline captured on the first explicit setbool IsDirty — workflow signal; set on any change; cleared by IsClean()bool Unsaved — persistence signal; true when Value != InitialValuevoid Saved() — sets InitialValue = Value (does not change IsDirty)void IsClean() — clears IsDirty (does not change baseline)event EventHandler<ValueChangedEventArgs<T>> ValueChanged — raised on any change (old/new provided)TrackedValues (aggregator)void Add(ITrackedValue item) / bool Remove(ITrackedValue item)bool IsDirty — true if any child IsDirtybool Unsaved — true if any child Unsavedvoid Saved() — calls Saved() on all childrenvoid IsClean() — calls IsClean() on all childrenIEnumerable<ITrackedValue> — enumerate childrenIsDirty to drive recalculations, redraws, and other downstream updates.IsClean() after those updates complete.Unsaved to determine whether persistence is required.Saved() after you commit the current value to storage.IsDirty = true, Unsaved = false (e.g., first explicit load into the tracker), orIsDirty = false, Unsaved = true (e.g., you cleaned workflow but still haven’t persisted changes).Create your own tracked type by deriving from TrackedValue<T>:
C#
public class TrackedGuid : TrackedValue
{
public TrackedGuid(Guid defaultValue = default) : base(defaultValue) { }
}
Show more lines
You immediately get:
InitialValue)IsDirty/Unsaved)ITrackedValue)Recommended test cases per type:
IsDirty == true, Unsaved == falseIsDirty == true, Unsaved == trueIsDirty == true, Unsaved == falseIsClean() clears IsDirty onlySaved() realigns baseline and leaves IsDirty unchangedMIT (see LICENSE).