Shos.UndoRedoList 1.0.8

dotnet add package Shos.UndoRedoList --version 1.0.8
                    
NuGet\Install-Package Shos.UndoRedoList -Version 1.0.8
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Shos.UndoRedoList" Version="1.0.8" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Shos.UndoRedoList" Version="1.0.8" />
                    
Directory.Packages.props
<PackageReference Include="Shos.UndoRedoList" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Shos.UndoRedoList --version 1.0.8
                    
#r "nuget: Shos.UndoRedoList, 1.0.8"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Shos.UndoRedoList@1.0.8
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Shos.UndoRedoList&version=1.0.8
                    
Install as a Cake Addin
#tool nuget:?package=Shos.UndoRedoList&version=1.0.8
                    
Install as a Cake Tool

Shos.UndoRedoList

NuGet License: MIT

English | 日本語

A List<T> and an ObservableCollection<T> with undo/redo support for .NET.

Replace your list with UndoRedoList<T> (or your ObservableCollection<T> with UndoRedoObservableCollection<T>), and every Add, Insert, Remove, RemoveAt, Clear and indexer assignment can be undone and redone.

var list = new UndoRedoList<string> { "apple", "banana" };
list[0] = "avocado";   // avocado, banana
list.Undo();           // apple, banana
list.Redo();           // avocado, banana

Features

  • Drop-in replacement: implements IList<T>, so existing code keeps working.
  • Undo/redo for every change: covers add, insert, remove, clear, and replacing an element via the indexer.
  • Grouped actions: several changes inside an ActionScope are undone and redone as one step.
  • Untracked changes: changes inside a DisabledUndoScope are not recorded (e.g. loading initial data).
  • Bounded history: the number of undo steps is limited (default: 100), so memory use doesn't grow without limit.
  • WPF / MVVM friendly: UndoRedoObservableCollection<T> raises CollectionChanged, so you can bind it to ItemsSource directly.
  • Thread-safe version: SynchronizedUndoRedoList<T> can be used from multiple threads.
  • Nullable-aware: the API is annotated for nullable reference types.
  • No dependencies: a small library that depends only on the .NET base class library.

Supported frameworks

Package version Target frameworks
1.0.8 .NET 8.0, .NET 10.0
1.0.7 .NET 8.0, .NET 10.0
1.0.6 .NET 8.0, .NET 10.0
1.0.5 .NET 8.0

Installation

Install Shos.UndoRedoList from NuGet.

dotnet add package Shos.UndoRedoList

Or, in the Package Manager Console of Visual Studio:

PM> Install-Package Shos.UndoRedoList

Or, in your project file:

<PackageReference Include="Shos.UndoRedoList" Version="1.0.8" />

All types are in the Shos.Collections namespace.

using Shos.Collections;

Quick start

using Shos.Collections;

var list = new UndoRedoList<string>();

list.Add("apple");
list.Add("banana");
list[0] = "avocado";
list.RemoveAt(1);
// list: avocado

list.Undo();   // list: avocado, banana   (RemoveAt is undone)
list.Undo();   // list: apple, banana     (list[0] = ... is undone)
list.Redo();   // list: avocado, banana   (list[0] = ... is redone)

Console.WriteLine(list.CanUndo); // True
Console.WriteLine(list.CanRedo); // True

Undo() and Redo() return false (and do nothing) when there is nothing to undo or redo. Use CanUndo / CanRedo to enable or disable your Undo/Redo buttons.

Usage

Undo several changes at once: ActionScope

All the changes made inside an ActionScope are recorded as one undo step.

var numbers = new UndoRedoList<int> { 1, 2, 3 };

using (new UndoRedoList<int>.ActionScope(numbers)) {
    numbers.Add(4);
    numbers.RemoveAt(0);
    numbers[0] = 20;
}
// numbers: 20, 3, 4

numbers.Undo();
// numbers: 1, 2, 3   (all three changes are undone at once)

ActionScope can't be nested. Starting a second ActionScope on the same list while one is active throws InvalidOperationException.

Make changes that can't be undone: DisabledUndoScope

Changes made inside a DisabledUndoScope are not recorded. This is useful for loading initial data.

var items = new UndoRedoList<string>();

using (new UndoRedoList<string>.DisabledUndoScope(items)) {
    items.Add("loaded 1");
    items.Add("loaded 2");
}

Console.WriteLine(items.CanUndo); // False

You can also set the UndoEnabled property directly.

Note: Recorded actions remember the indexes of the elements they changed. If you make untracked changes after some changes have been recorded, call ClearUndo() afterwards. Otherwise, the next undo may be applied to the wrong position.

Limit the number of undo steps

Pass maximumUndoTimes to the constructor (default: 100, minimum: 2). When the limit is exceeded, the oldest step is discarded.

var list = new UndoRedoList<int>(maximumUndoTimes: 3);
for (var i = 1; i <= 5; i++)
    list.Add(i);

// Only the last 3 additions can be undone.
while (list.Undo())
    ;
// list: 1, 2

Clear the undo/redo history

list.ClearUndo(); // CanUndo and CanRedo become false. The elements are not changed.

Redo history is discarded by a new change

Like most editors, making a new change after undoing discards the steps that could be redone.

var list = new UndoRedoList<int> { 1, 2 };
list.Undo();      // list: 1        CanRedo: True
list.Add(3);      // list: 1, 3     CanRedo: False ("Add(2)" can no longer be redone)

WPF / MVVM: UndoRedoObservableCollection<T>

UndoRedoObservableCollection<T> raises CollectionChanged for every change, including those made by Undo() and Redo(). Bind it to ItemsSource just like an ObservableCollection<T>.

CanUndo and CanRedo are already up to date when CollectionChanged is raised, so you can refresh your commands there.

using Prism.Commands; // Any MVVM library works (e.g. CommunityToolkit.Mvvm).
using Shos.Collections;

public class MainViewModel
{
    public UndoRedoObservableCollection<string> Items { get; } = new();

    public DelegateCommand UndoCommand { get; }
    public DelegateCommand RedoCommand { get; }

    public MainViewModel()
    {
        UndoCommand = new DelegateCommand(() => Items.Undo(), () => Items.CanUndo);
        RedoCommand = new DelegateCommand(() => Items.Redo(), () => Items.CanRedo);

        Items.CollectionChanged += (_, _) => {
            UndoCommand.RaiseCanExecuteChanged();
            RedoCommand.RaiseCanExecuteChanged();
        };
    }
}
<ListBox ItemsSource="{Binding Items}" />
<Button Content="Undo" Command="{Binding UndoCommand}" />
<Button Content="Redo" Command="{Binding RedoCommand}" />

CollectionChanged is not raised by ClearUndo() or by changing UndoEnabled. Refresh your commands yourself after calling them.

See Shos.UndoRedoList.SampleApp for a complete WPF sample.

Shos.UndoRedoList.SampleApp

Use another IList<T> as the inner list: UndoRedoList<TElement, TList>

UndoRedoList<T> uses List<T> internally. To use another collection, specify it as TList. It must implement IList<TElement> and have a parameterless constructor.

var list = new UndoRedoList<int, ObservableCollection<int>>();
list.Add(1);

ObservableCollection<int> inner = list.List; // Read the inner collection.

Use from multiple threads: SynchronizedUndoRedoList<T>

UndoRedoList<T> is not thread-safe. To use the list from multiple threads, use SynchronizedUndoRedoList<T> instead. Every member is synchronized with one lock (SyncRoot).

var list = new SynchronizedUndoRedoList<int>();
Parallel.For(0, 100, i => list.Add(i)); // OK

Values such as Count and CanUndo may be out of date as soon as you read them, so don't "check then act":

// NG: another thread may change the list between the check and the action.
if (list.CanUndo)
    list.Undo();
if (list.Count > 0)
    list.RemoveAt(0);

// OK: use the return value.
var undone = list.Undo();

// OK: do several operations atomically with ExecuteAsOneAction (they are undone as one step).
var removed = list.ExecuteAsOneAction(items => {
    if (items.Count == 0)
        return false;
    items.RemoveAt(0);
    return true;
});

Use ExecuteAsOneAction instead of ActionScope, and ExecuteWithoutUndo instead of DisabledUndoScope:

list.ExecuteAsOneAction(items => {   // Undone as one step.
    items.Add(1);
    items.Add(2);
});
list.ExecuteWithoutUndo(items => items.Add(3)); // Not recorded.

The delegate runs while holding the lock. Keep it short, don't keep items after it returns, don't call ExecuteAsOneAction from it, and don't await in it.

Enumerating (foreach) returns a snapshot taken when enumeration starts, so other threads can change the list while you enumerate it. ToArray() also returns a copy.

API

Types

Type Description
UndoRedoList<TElement> A List<TElement> that supports undo/redo.
UndoRedoObservableCollection<TElement> An ObservableCollection<TElement> that supports undo/redo. Implements INotifyCollectionChanged.
UndoRedoList<TElement, TList> The base class. Adds undo/redo to any TList : IList<TElement>, new().
UndoRedoList<TElement, TList>.ActionScope IDisposable. Groups the changes made in the scope into one undo step.
UndoRedoList<TElement, TList>.DisabledUndoScope IDisposable. Changes made in the scope are not recorded.
SynchronizedUndoRedoList<TElement> A thread-safe UndoRedoList<TElement>.
SynchronizedUndoRedoList<TElement, TList> A thread-safe UndoRedoList<TElement, TList>.

Members of UndoRedoList<TElement, TList>

In addition to the IList<TElement> members:

Member Description
UndoRedoList(int maximumUndoTimes = 100) Constructor. maximumUndoTimes is the maximum number of undo steps (2 or more).
bool Undo() Undoes the last change. Returns false if there is nothing to undo.
bool Redo() Redoes the last undone change. Returns false if there is nothing to redo.
bool CanUndo Whether Undo() can be performed.
bool CanRedo Whether Redo() can be performed.
bool UndoEnabled When false, changes are not recorded. Default: true.
void ClearUndo() Clears the undo/redo history.
void BeginActions() / void EndActions() Low-level versions of ActionScope. Prefer ActionScope.
TList List The inner collection. Read only from it. See Notes.

Members of SynchronizedUndoRedoList<TElement, TList>

In addition to the IList<TElement> members, Undo(), Redo(), CanUndo, CanRedo and ClearUndo():

Member Description
SynchronizedUndoRedoList(int maximumUndoTimes = 100) Constructor. maximumUndoTimes is the maximum number of undo steps (2 or more).
object SyncRoot The lock object. Lock it to do several operations atomically.
bool UndoEnabled Read only. Use ExecuteWithoutUndo to make changes that are not recorded.
void ExecuteAsOneAction(Action<IList<TElement>>) Runs the action while holding the lock. Its changes are undone as one step.
TResult ExecuteAsOneAction<TResult>(Func<IList<TElement>, TResult>) Same as above, and returns the result.
void ExecuteWithoutUndo(Action<IList<TElement>>) Runs the action while holding the lock. Its changes are not recorded.
TElement[] ToArray() Returns a copy of the elements.
GetEnumerator() Enumerates a snapshot of the elements.

Notes

  • Only changes to the collection are recorded. Changes to the properties of an element (e.g. list[0].Name = "x") are not recorded. To make such a change undoable, replace the element: list[0] = newItem.
  • Don't modify List directly. Changes made through the inner collection are not recorded and make the history inconsistent.
  • Clear() on an empty list is not recorded.
  • UndoRedoList<T> and UndoRedoObservableCollection<T> are not thread-safe. Use them from one thread (e.g. the UI thread), or use SynchronizedUndoRedoList<T>.
  • If an exception is thrown inside an ActionScope, the changes made before the exception are recorded as one undo step.

How it works

Each change is recorded as an action that knows how to undo and redo itself (add ⇔ remove, replace ⇔ replace back). Actions are stored in a ring buffer of size maximumUndoTimes, so the history uses a fixed amount of memory, and the oldest actions are overwritten when it is full.

The class diagrams of the public types (members of IList<T> are omitted):

Collections

classDiagram
    class IList["IList&lt;TElement&gt;"] {
        <<interface>>
    }
    class INotifyCollectionChanged {
        <<interface>>
    }
    class IDisposable {
        <<interface>>
    }
    class UndoRedoList2["UndoRedoList&lt;TElement, TList&gt;"] {
        +bool CanUndo
        +bool CanRedo
        +bool UndoEnabled
        +TList List
        +UndoRedoList(int maximumUndoTimes = 100)
        +Undo() bool
        +Redo() bool
        +ClearUndo() void
        +BeginActions() void
        +EndActions() void
    }
    class ActionScope["UndoRedoList&lt;TElement, TList&gt;.ActionScope"] {
        +ActionScope(UndoRedoList&lt;TElement, TList&gt; list)
        +Dispose() void
    }
    class DisabledUndoScope["UndoRedoList&lt;TElement, TList&gt;.DisabledUndoScope"] {
        +DisabledUndoScope(UndoRedoList&lt;TElement, TList&gt; list)
        +Dispose() void
    }
    class UndoRedoList1["UndoRedoList&lt;TElement&gt;"] {
        +UndoRedoList(int maximumUndoTimes = 100)
    }
    class UndoRedoObservableCollection["UndoRedoObservableCollection&lt;TElement&gt;"] {
        +event NotifyCollectionChangedEventHandler? CollectionChanged
        +UndoRedoObservableCollection(int maximumUndoTimes = 100)
    }
    class SynchronizedUndoRedoList2["SynchronizedUndoRedoList&lt;TElement, TList&gt;"] {
        +object SyncRoot
        +bool CanUndo
        +bool CanRedo
        +bool UndoEnabled
        +SynchronizedUndoRedoList(int maximumUndoTimes = 100)
        +Undo() bool
        +Redo() bool
        +ClearUndo() void
        +ExecuteAsOneAction(Action&lt;IList&lt;TElement&gt;&gt; action) void
        +ExecuteAsOneAction&lt;TResult&gt;(Func&lt;IList&lt;TElement&gt;, TResult&gt; function) TResult
        +ExecuteWithoutUndo(Action&lt;IList&lt;TElement&gt;&gt; action) void
        +ToArray() TElement[]
    }
    class SynchronizedUndoRedoList1["SynchronizedUndoRedoList&lt;TElement&gt;"] {
        +SynchronizedUndoRedoList(int maximumUndoTimes = 100)
    }

    IList <|.. UndoRedoList2
    IList <|.. SynchronizedUndoRedoList2
    UndoRedoList2 <|-- UndoRedoList1
    UndoRedoList2 <|-- UndoRedoObservableCollection
    INotifyCollectionChanged <|.. UndoRedoObservableCollection
    SynchronizedUndoRedoList2 <|-- SynchronizedUndoRedoList1
    IDisposable <|.. ActionScope
    IDisposable <|.. DisabledUndoScope
    ActionScope ..> UndoRedoList2
    DisabledUndoScope ..> UndoRedoList2

Supporting types

classDiagram
    class IEnumerable["IEnumerable&lt;TElement&gt;"] {
        <<interface>>
    }
    class IEquatable["IEquatable&lt;ModuloArithmetic&gt;"] {
        <<interface>>
    }
    class RingBuffer["RingBuffer&lt;TElement&gt;"] {
        +int Count
        +ModuloArithmetic TopIndex
        +ModuloArithmetic BottomIndex
        +TElement this[ModuloArithmetic index]
        +RingBuffer(int size = 100)
        +Add(TElement element) void
        +Clear() void
        +RemoveAfter(ModuloArithmetic index) void
        +Remove() bool
    }
    class UndoRedoRingBuffer["UndoRedoRingBuffer&lt;TElement&gt;"] {
        +ModuloArithmetic CurrentIndex
        +TElement Current
        +bool CanGoBackward
        +bool CanGoForward
        +UndoRedoRingBuffer(int size = 100)
        +Add(TElement element) void
        +Clear() void
        +GoBackward() bool
        +GoForward() bool
    }
    class ModuloArithmetic {
        <<struct>>
        +int DefaultDivisor$
        +int Value
        +int Divisor
        +bool IsValid
        +ModuloArithmetic Previous
        +ModuloArithmetic Next
        +ModuloArithmetic InvalidItem
        +ModuloArithmetic(int divisor = 100, bool isValid = true)
        +Equals(ModuloArithmetic item) bool
        +MovePrevious() void
        +MoveNext() void
        +operator ++(ModuloArithmetic item)$ ModuloArithmetic
        +operator --(ModuloArithmetic item)$ ModuloArithmetic
        +operator -(ModuloArithmetic item1, ModuloArithmetic item2)$ int
        +operator ==(ModuloArithmetic item1, ModuloArithmetic item2)$ bool
        +operator !=(ModuloArithmetic item1, ModuloArithmetic item2)$ bool
    }
    class EnumerableExtensions {
        <<static>>
        +ForEach&lt;TElement&gt;(IEnumerable&lt;TElement&gt; this, Action&lt;TElement&gt; action)$ void
        +ReverseForEach&lt;TElement&gt;(IEnumerable&lt;TElement&gt; this, Action&lt;TElement&gt; action)$ void
    }

    IEnumerable <|.. RingBuffer
    RingBuffer <|-- UndoRedoRingBuffer
    IEquatable <|.. ModuloArithmetic
    RingBuffer --> ModuloArithmetic

Repository structure

Project Description
Shos.UndoRedoList The library.
Shos.UndoRedoList.Tests Unit tests (MSTest).
Shos.UndoRedoList.SampleApp A WPF sample app using UndoRedoObservableCollection<T>.
Shos.UndoRedoList.PerformanceTests A console app that runs many operations.

To build and test (requires the .NET 10 SDK, plus the .NET 8 runtime to run the tests for .NET 8.0):

dotnet build
dotnet test Shos.UndoRedoList.Tests

Release notes

1.0.8

  • Changed: the API is now annotated for nullable reference types (e.g. UndoRedoObservableCollection<T>.CollectionChanged is NotifyCollectionChangedEventHandler?).
  • Changed: Insert with an invalid index now throws ArgumentOutOfRangeException with the standard .NET message, which includes the actual value.
  • Rewrote the source code in C# 12 (file-scoped namespaces, primary constructors, collection expressions, etc.). The public API is unchanged.
  • Replaced the PlantUML class diagram images with Mermaid class diagrams of the public types in the README.

1.0.7

  • Added SynchronizedUndoRedoList<T>, a thread-safe version.

1.0.6

  • Added support for .NET 10.0 (targets .NET 8.0 and .NET 10.0).
  • Fixed: after undoing all changes, a new change didn't discard the undone changes, so they could still be undone/redone and corrupted the list.
  • Fixed: after the undo history reached maximumUndoTimes, undoing all changes and then redoing redid the wrong change.
  • Fixed: a change that failed with an exception (e.g. Insert with an invalid index) was recorded in the history.
  • Fixed: CanUndo / CanRedo were out of date in CollectionChanged handlers during Undo().
  • Fixed: ModuloArithmetic.Equals(object) threw an exception for null or other types.
  • Changed: Clear() on an empty list is no longer recorded.
  • Changed: invalid sizes now throw ArgumentOutOfRangeException with the parameter name.
  • The NuGet package now includes XML documentation (IntelliSense).

1.0.5 and earlier

See the commit history.

Author info

Fujio Kojima: a software developer in Japan

  • Microsoft MVP for Development Tools - Visual C# (Jul. 2005 - Dec. 2014)
  • Microsoft MVP for .NET (Jan. 2015 - Oct. 2015)
  • Microsoft MVP for Visual Studio and Development Technologies (Nov. 2015 - Jun. 2018)
  • Microsoft MVP for Developer Technologies (Nov. 2018 - Jun. 2025)
  • MVP Profile
  • Blog (Japanese)
  • Web Site (Japanese)
  • Twitter
  • Instagram

License

This library is under the MIT License.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.
  • net8.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.8 88 10/10/2026
1.0.7 84 10/9/2026
1.0.6 83 10/9/2026
1.0.5 388 3/28/2024
1.0.4 248 3/26/2024
1.0.3 757 12/10/2020
1.0.2 697 12/9/2020
1.0.1 618 12/8/2020
1.0.0 655 12/8/2020