Shos.UndoRedoList 1.0.7

There is a newer version of this package available.
See the version list below for details.
dotnet add package Shos.UndoRedoList --version 1.0.7
                    
NuGet\Install-Package Shos.UndoRedoList -Version 1.0.7
                    
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.7" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Shos.UndoRedoList" Version="1.0.7" />
                    
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.7
                    
#r "nuget: Shos.UndoRedoList, 1.0.7"
                    
#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.7
                    
#: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.7
                    
Install as a Cake Addin
#tool nuget:?package=Shos.UndoRedoList&version=1.0.7
                    
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.
  • No dependencies: a small library that depends only on the .NET base class library.

Supported frameworks

Package version Target frameworks
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.7" />

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 diagram (PlantUML sources are in Shos.UndoRedoList/Documents/ClassDiagrams):

Shos.UndoRedoList class diagram

Larger class diagram image is here.

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.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 90 10/10/2026
1.0.7 88 10/9/2026
1.0.6 87 10/9/2026
1.0.5 388 3/28/2024
1.0.4 249 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 656 12/8/2020