How User Input Silently Removes a WPF Binding Written Without Mode

Bindings to TreeViewItem.IsExpanded or ColumnDefinition.Width without Mode break after the expander button or a GridSplitter is used. Mode=TwoWay keeps them.

Overview

Keeping the open state of TreeView nodes in a view model and binding IsExpanded through ItemContainerStyle is a common MVVM pattern.
When the binding is written without Mode, however, a node that the user opened once with the expander button no longer follows the view model.
No exception is thrown, and no binding error appears in the Output window.

The cause is a combination of two things.
First, the direction of a binding written without Mode follows the default of the target property, and the default of TreeViewItem.IsExpanded is one-way.
Second, the expander button writes to IsExpanded through a binding inside the template, and that write stops the one-way binding from working.

This article measures the default direction of properties that are often bound without Mode, and what happens to the binding when real mouse and keyboard input is used.
It then shows the conditions under which the binding is removed, and how to write the binding so that it stays.


Prerequisites / Environment


Symptom

A folder tree is displayed, and the open state of each node is bound to IsExpanded in the view model.
The Binding in the Setter has no Mode.

<TreeView x:Name="folderTree" ItemsSource="{Binding Folders}">
  <TreeView.ItemContainerStyle>
    <Style TargetType="TreeViewItem">
      <Setter Property="IsExpanded" Value="{Binding IsExpanded}" />
    </Style>
  </TreeView.ItemContainerStyle>
  <TreeView.ItemTemplate>
    <HierarchicalDataTemplate ItemsSource="{Binding Children}">
      <TextBlock Text="{Binding Name}" />
    </HierarchicalDataTemplate>
  </TreeView.ItemTemplate>
</TreeView>

Changing IsExpanded in the view model opens and closes the node.
A node opened with the expander button, however, does not close when the view model then sets IsExpanded to false.
The following table shows what happened to the binding after each input.
Rows 1 to 8 use this XAML (rows 6 to 8 add Mode=TwoWay), rows 9 to 16 are other setups for comparison, and row 17 checks how errors are counted.

binding and input after the input: displayed / source binding after the input value source after the input displayed after the source is assigned binding errors
ItemContainerStyle {Binding IsExpanded}, click the expander button True / False removed Local True 0
ItemContainerStyle {Binding IsExpanded}, select the node and press the right arrow key True / False kept Style, expression, current False 0
ItemContainerStyle {Binding IsExpanded}, double-click the header True / False kept Style, expression, current False 0
ItemContainerStyle {Binding IsExpanded}, set IsChecked of the expander button to True from code True / False removed Local True 0
ItemContainerStyle {Binding IsExpanded}, click the expander button, then ClearValue(IsExpanded) False / False kept Style, expression True 0
ItemContainerStyle {Binding IsExpanded, Mode=TwoWay}, click the expander button True / True kept Style, expression False 0
ItemContainerStyle {Binding IsExpanded, Mode=TwoWay}, select the node and press the right arrow key True / True kept Style, expression False 0
ItemContainerStyle {Binding IsExpanded, Mode=TwoWay}, double-click the header True / True kept Style, expression False 0
TreeViewItem IsExpanded="{Binding Value}" written on the item, click the expander button True / False removed Local True 0
TreeViewItem IsExpanded="{Binding Value}" written on the item, click, then ClearValue(IsExpanded) False / False removed Default False 0
Expander IsExpanded="{Binding Value}", click the header True / True kept Local, expression False 0
Expander IsExpanded="{Binding Value, Mode=OneWay}", click the header True / False removed Local True 0
MenuItem IsCheckable IsChecked="{Binding Value}", click it True / True kept Local, expression False 0
CheckBox IsChecked="{Binding Value}", click it True / True kept Local, expression False 0
ColumnDefinition Width="{Binding Value}", drag the GridSplitter 60 DIP to the right 160 / 100 removed Local 160 0
ColumnDefinition Width="{Binding Value, Mode=TwoWay}", drag the GridSplitter 60 DIP to the right 160 / 160 kept Local, expression 100 0
check of the error count: TreeViewItem IsExpanded="{Binding Missing}" (no such property), no input False / False kept Local, expression False 1

Measured on .NET 10 / Windows 11 (default theme) by operating each condition once with real mouse and keyboard input. Row 4 set the value from code without real input, and rows 5 and 10 called ClearValue from code after the click. The GridSplitter was dragged 60 DIP (the WPF unit that does not depend on display scaling). “Binding after the input” is whether BindingOperations.GetBindingExpressionBase returned null, and “removed” includes a Setter binding hidden by a local value. The source starts at False (100 in the ColumnDefinition rows), and “displayed after the source is assigned” is the value after the source was assigned its value from before the input (in rows 5 and 10 only, after True was assigned to the source). “Value source after the input” is the BaseValueSource from DependencyPropertyHelper.GetValueSource; “expression” means IsExpression is True, and “current” means IsCurrent is True. “Binding errors” counts the errors and warnings written to PresentationTraceSources.DataBindingSource during the input, and the last row confirms that a binding to a missing path is counted as 1 by this method.

In row 1, where the expander button was clicked, the display became True but the source stayed False.
The binding stopped working, and assigning False to the source again left the display at True.
There were no binding errors, so the Output window gives no hint.


What Happens Internally

Without Mode, the Property Decides the Direction

A Binding without Mode uses BindingMode.Default, and its direction is decided by the metadata of the target property (BindingMode).
If BindsTwoWayByDefault in the metadata is True, the binding is two-way; if it is False, the binding is one-way.
The following table shows the metadata of properties that are often bound without Mode.

property BindsTwoWayByDefault DefaultUpdateSourceTrigger
TreeViewItem.IsExpanded False PropertyChanged
TreeViewItem.IsSelected True PropertyChanged
Expander.IsExpanded True PropertyChanged
ToggleButton.IsChecked True PropertyChanged
ToggleButton.IsChecked (CheckBox) True PropertyChanged
MenuItem.IsChecked True PropertyChanged
MenuItem.IsSubmenuOpen True PropertyChanged
ComboBox.IsDropDownOpen True PropertyChanged
ComboBox.Text True PropertyChanged
Popup.IsOpen True PropertyChanged
ContextMenu.IsOpen True PropertyChanged
ToolTip.IsOpen True PropertyChanged
Selector.SelectedIndex (ListBox) True PropertyChanged
Selector.SelectedItem (ListBox) True PropertyChanged
ListBoxItem.IsSelected True PropertyChanged
TabItem.IsSelected True PropertyChanged
TextBox.Text True LostFocus
RangeBase.Value (Slider) True PropertyChanged
DatePicker.SelectedDate True PropertyChanged
DatePicker.Text False PropertyChanged
DatePicker.IsDropDownOpen True PropertyChanged
Calendar.SelectedDate True PropertyChanged
ColumnDefinition.Width False PropertyChanged
RowDefinition.Height False PropertyChanged
Window.WindowState True PropertyChanged
Window.Left False PropertyChanged
Window.Top False PropertyChanged
FrameworkElement.Width (Window) False PropertyChanged

Values read on .NET 10 from the FrameworkPropertyMetadata returned by GetMetadata for each property. Rows with a type in parentheses were read for that type; the other rows were read for the type at the start of the name.

On the same TreeViewItem, IsSelected is True and IsExpanded is False.
Expander.IsExpanded is True, so the default direction of “open and close” differs between controls.
ColumnDefinition.Width and RowDefinition.Height were also False.
DefaultUpdateSourceTrigger was LostFocus only for TextBox.Text, and PropertyChanged for the others.

The Expander Button Writes Through a Binding in the Template

The expander button is a ToggleButton in the default template of TreeViewItem.
How its IsChecked is bound to IsExpanded was read from the template.

element in the template Binding Mode RelativeSource
TreeViewItem: ToggleButton "Expander" IsChecked {Binding IsExpanded} Default TemplatedParent
Expander: ToggleButton "HeaderSite" IsChecked {Binding IsExpanded} TwoWay TemplatedParent

Values read on .NET 10 / Windows 11 (default theme) with BindingOperations.GetBinding for IsChecked of the ToggleButton inside a TreeViewItem and an Expander whose templates were applied.

IsChecked of the expander button is bound to IsExpanded of the TreeViewItem that the template is applied to (TemplatedParent), with {Binding IsExpanded} and no Mode.
The default of ToggleButton.IsChecked is two-way (the “ToggleButton.IsChecked” row of the defaults table above), so this binding works in both directions.

Pressing the button makes this binding write a value to TreeViewItem.IsExpanded.
Row 4, which set IsChecked of the button to True from code without any real click, gave the same result as the click.
The result of the click is reproduced by a write along this path alone.

From the point of view of TreeViewItem.IsExpanded, this write is an ordinary set of a value (a local value).
As row 1 of the table shows, when a value was set on a property with a one-way binding, the value source became Local and the binding stopped working.

A Setter Binding Is Hidden; a Binding Written on the Element Is Replaced

How the binding stops working depends on where it is written.

A binding in an ItemContainerStyle Setter has lower precedence than a local value.
Once a local value is set, the Setter binding is hidden.
In row 5, where the local value was removed with ClearValue after the click, the value source went back to “Style, expression”.
Setting the source to True afterward made the display True as well.
The Setter binding had not been removed; it had only been hidden.

With IsExpanded="{Binding Value}" written directly on a TreeViewItem, the binding itself sits at the local value level.
The value written by the click replaced the binding (row 9), and after ClearValue the value source became Default, and the display stayed False even when the source was set to True (row 10).

When the Target Is Two-Way, the Value Goes to the Source

In row 6, written with Mode=TwoWay, the value source stayed “Style, expression” after the same click, and the source was updated to True.
A write to a property with a two-way binding went through the binding to the source, and the binding kept working.

The Right Arrow Key and Double-Click Write with SetCurrentValue

In rows 2 and 3, opened with the right arrow key and a double-click on the header, the binding stayed.
The value source was “Style, expression, current”, with IsCurrent set to True.
This shows that the value was set with SetCurrentValue (ValueSource.IsCurrent).
SetCurrentValue changes only the effective value without changing the value source, and keeps the binding (DependencyObject.SetCurrentValue).

The binding is still one-way, however, so the source was not updated.
When the view model raised a change notification for IsExpanded (even with the value still False), the display was set back to the source value, and the node that the user opened closed.
In rows 7 and 8, written with Mode=TwoWay, the same input updated the source to True.
With a two-way binding the value went to the source, and the value source was not marked “current”.


Consequences of the Write Path

The same property gives different results depending on the input path.
Among the real inputs to TreeViewItem.IsExpanded (the expander button, the right arrow key, and a double-click on the header), only a click on the expander button removed the binding.
The right arrow key and a double-click on the header did not.
Trying a reported defect with the right arrow key or a double-click does not reproduce it.

Whether the binding is removed depends on the actual direction of the binding being written to.
Expander.IsExpanded defaults to True; without Mode, the binding stayed after a click and the source was updated (row 11).
In row 12, with Mode=OneWay written on the same Expander, the value source became Local and the source stayed False, even though the template binds the header with Mode=TwoWay (see the template table above).
Without Mode, that direction is the default of the property.
MenuItem.IsChecked and IsChecked of a CheckBox also default to True, and the binding stayed without Mode (rows 13 and 14).

The same happens for a property that is one-way by default and that the input writes with an ordinary set.
ColumnDefinition.Width defaults to False; dragging the GridSplitter removed the binding, and the source stayed 100 (row 15).
With Mode=TwoWay, the binding stayed and the source was updated to 160 (row 16).
RowDefinition.Height, DatePicker.Text, Window.Left, and others also default to False, but the results of operating them were not measured in this article.


Implementation Example

Write Mode=TwoWay on Properties That the User Changes

For TreeViewItem.IsExpanded, Mode=TwoWay is written on the Setter binding.
The Setter in the XAML from the “Symptom” section becomes the following.

<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />

Whether the node was opened with the expander button, the right arrow key, or a double-click on the header, IsExpanded in the view model became True (rows 6 to 8).
Setting it to False from the view model afterward closed the node.

A column resized with a GridSplitter also gets Mode=TwoWay.
The type of ColumnDefinition.Width is GridLength, so the view model property is a GridLength as well.

<Grid>
  <Grid.ColumnDefinitions>
    <ColumnDefinition Width="{Binding NavigationWidth, Mode=TwoWay}" />
    <ColumnDefinition Width="Auto" />
    <ColumnDefinition />
  </Grid.ColumnDefinitions>
  <GridSplitter Grid.Column="1" Width="6" HorizontalAlignment="Center" />
</Grid>

HorizontalAlignment of the GridSplitter is set to Center because the default Right changes which pair of columns is resized (measured on the GridSplitter demo page).
Dragging in this setup updated the width in the view model (row 16).

Checking Whether the Binding Is Still Attached

A removed binding raises no error, so checking it requires reading it in code.
The following class has a method that returns the default direction of a property, and a method that writes the state of the binding and where the value comes from.
The measurement in this article reads the values with the same APIs.

using System.Diagnostics;
using System.Windows;
using System.Windows.Data;

public static class BindingReport
{
    // The metadata default that decides the direction of a binding written without Mode.
    // Passing the instance returns the metadata overridden for its type.
    public static bool BindsTwoWayByDefault(DependencyObject target, DependencyProperty property) =>
        property.GetMetadata(target) is FrameworkPropertyMetadata metadata && metadata.BindsTwoWayByDefault;

    // Whether a binding is attached, and where the value comes from (written to the Output window in Debug builds).
    public static void Write(DependencyObject target, DependencyProperty property)
    {
        BindingExpressionBase expression = BindingOperations.GetBindingExpressionBase(target, property);
        ValueSource source = DependencyPropertyHelper.GetValueSource(target, property);
        Debug.WriteLine(
            $"{property.Name}: binding {(expression == null ? "none" : "attached")}, " +
            $"two-way by default {BindsTwoWayByDefault(target, property)}, " +
            $"{source.BaseValueSource}" +
            (source.IsExpression ? ", expression" : "") +
            (source.IsCurrent ? ", current" : ""));
    }
}

For a TreeViewItem, the container is obtained with an ItemContainerGenerator and passed in.
Top-level nodes come from the ItemContainerGenerator of the TreeView, while child nodes come from the ItemContainerGenerator of their parent TreeViewItem.

var item = (TreeViewItem)folderTree.ItemContainerGenerator.ContainerFromItem(folder);
BindingReport.Write(item, TreeViewItem.IsExpandedProperty);

binding none with Local means the binding was replaced by a local value, a Setter binding is hidden by one, or there was no binding to begin with (in the table, rows 1 and 9, for example, appeared this way).
binding none with Default is, for example, the state after ClearValue on a replaced binding (row 10).
A property with neither a binding nor a value set from the start also appears this way.
expression with current means the binding is still attached, but the value was changed with SetCurrentValue.


Caveats


Summary

A binding written without Mode is removed by user input when the following two conditions are both met.

A binding written on the element is then replaced, and a Setter binding is hidden; either way, the binding stops working.
The source is not updated, and no error is raised.
When the view model holds a value that the user changes, Mode=TwoWay should be written instead of relying on the default of the property.
Whether the binding has been removed can be checked with BindingOperations.GetBindingExpressionBase and DependencyPropertyHelper.GetValueSource.