diff --git a/lazy_player/file_model.py b/lazy_player/file_model.py
index 3054a2c..0dffe59 100644
--- a/lazy_player/file_model.py
+++ b/lazy_player/file_model.py
@@ -8,75 +8,66 @@ from typing import Optional, overload
 
 from gi.repository import Gio, GObject
 
+from .reactive import Ref, Watcher
+
 
 class FileType(Enum):
     DIRECTORY = auto()
     VIDEO = auto()
 
 
-class FileItem(GObject.Object):
+class FileItem(GObject.Object, Watcher):
     file_type: FileType
     full_path: Path
-    thumbnail: bytes
-    attempted_thumbnail: bool
 
-    _has_thumbnail: bool
+    thumbnail: Ref[bytes]
+    attempted_thumbnail: Ref[bool]
+
+    saved_position: Ref[int]
+    saved_duration: Ref[int]
+    saved_subtitle_track: Ref[int]
+    saved_audio_track: Ref[int]
 
     __gtype_name__ = "FileItem"
 
     def __init__(self, name: str, file_type: FileType, full_path: Path):
         super().__init__()
+
         self.name = name
         self.file_type = file_type
         self.full_path = full_path
-        self.thumbnail = b""
-        self.attempted_thumbnail = False
-        self._has_thumbnail = False
 
-    @GObject.Property(type=GObject.TYPE_UINT64)
-    def saved_position(self) -> int:
-        return self._load_attribute("position", 0)
+        self.thumbnail = Ref(b"")
+        self.attempted_thumbnail = Ref(False)
 
-    @saved_position.setter
-    def set_saved_position(self, value: int):
-        self._save_attribute("position", value if value else None)
-        self.notify("saved-position")
+        self.saved_position = Ref(self._load_attribute("position", 0))
+        self.saved_duration = Ref(self._load_attribute("duration", 1))
+        self.saved_subtitle_track = Ref(self._load_attribute("subtitle_track", -2))
+        self.saved_audio_track = Ref(self._load_attribute("audio_track", 0))
 
-    @GObject.Property(type=GObject.TYPE_UINT64)
-    def saved_duration(self):
-        return self._load_attribute("duration", 1) or 1
+        self.watch_all()
 
-    @saved_duration.setter
-    def set_saved_duration(self, value: int):
-        self._save_attribute("duration", value if value > 0 else None)
-        self.notify("saved-duration")
+    def _watch_saved_position(self):
+        saved_position = self.saved_position.value
+        self._save_attribute("position", saved_position if saved_position > 0 else None)
 
-    @GObject.Property(type=int)
-    def saved_subtitle_track(self):
-        return self._load_attribute("subtitle_track", -2)
+    def _watch_saved_duration(self):
+        saved_duration = self.saved_duration.value
+        self._save_attribute("duration", saved_duration if saved_duration > 0 else None)
 
-    @saved_subtitle_track.setter
-    def set_saved_subtitle_track(self, value: int):
-        self._save_attribute("subtitle_track", value if value >= -1 else None)
-        self.notify("saved-subtitle-track")
+    def _watch_saved_subtitle_track(self):
+        saved_subtitle_track = self.saved_subtitle_track.value
+        self._save_attribute(
+            "subtitle_track",
+            saved_subtitle_track if saved_subtitle_track > -2 else None,
+        )
 
-    @GObject.Property(type=int)
-    def saved_audio_track(self):
-        return self._load_attribute("audio_track", 0)
-
-    @saved_audio_track.setter
-    def set_saved_audio_track(self, value: int):
-        self._save_attribute("audio_track", value if value > 0 else None)
-        self.notify("saved-audio-track")
-
-    @GObject.Property(type=bool, default=False)
-    def has_thumbnail(self):
-        return self._has_thumbnail
-
-    @has_thumbnail.setter
-    def set_has_thumbnail(self, value: bool):
-        self._has_thumbnail = value
-        self.notify("has-thumbnail")
+    def _watch_saved_audio_track(self):
+        saved_audio_track = self.saved_audio_track.value
+        self._save_attribute(
+            "audio_track",
+            saved_audio_track if saved_audio_track > -1 else None,
+        )
 
     @overload
     def _load_attribute(self, name: str, dfl: str) -> str: ...
diff --git a/lazy_player/main_window.py b/lazy_player/main_window.py
index f39aae7..bb4337c 100644
--- a/lazy_player/main_window.py
+++ b/lazy_player/main_window.py
@@ -129,7 +129,6 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
 
         # Setup video player
         self.video_player = VideoPlayer(self.video_widget)
-        self.watch(self._sync_overlay_grid)
 
         # Add both main menu and overlay to stack
         self.stack.add_named(main_box, "menu")
@@ -189,8 +188,8 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
                     self._navigate_to(file_item.full_path)
                     return
 
-                position = file_item.saved_position
-                duration = file_item.saved_duration
+                position = file_item.saved_position.value
+                duration = file_item.saved_duration.value
 
                 if (position / duration) >= 0.99:
                     position = 0
@@ -199,8 +198,8 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
                 self.video_player.play(
                     file_item.full_path,
                     position,
-                    file_item.saved_subtitle_track,
-                    file_item.saved_audio_track,
+                    file_item.saved_subtitle_track.value,
+                    file_item.saved_audio_track.value,
                 )
                 self.last_position_save = self.now
 
@@ -225,6 +224,8 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
 
         self.set_child(self.stack)
 
+        self.watch_all()
+
     @property
     def now(self) -> float:
         frame_clock = self.get_frame_clock()
@@ -266,8 +267,8 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
                 icon.set_from_icon_name("folder-symbolic")
                 icon.set_css_classes(["file-icon"])
             else:
-                position = item.saved_position
-                duration = item.saved_duration
+                position = item.saved_position.value
+                duration = item.saved_duration.value
 
                 if position == 0:
                     icon.set_from_icon_name("media-playback-start-symbolic")
@@ -323,8 +324,8 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
 
         if file_item := self.selection:
             # Update thumbnail if available
-            if file_item.thumbnail:
-                gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail))
+            if file_item.thumbnail.value:
+                gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail.value))
                 texture = Gdk.Texture.new_from_bytes(gbytes)
                 self.thumbnail_image.set_paintable(texture)
             else:
@@ -387,7 +388,7 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
 
                 file_item = self.selection
                 if file_item is not None:
-                    file_item.saved_subtitle_track = index - 1
+                    file_item.saved_subtitle_track.value = index - 1
             else:
                 self.show_overlay_text("No subtitles available")
 
@@ -400,7 +401,7 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
                 self.show_overlay_text(f"Audio #{index} ({lang})")
                 file_item = self.selection
                 if file_item is not None:
-                    file_item.saved_audio_track = index - 1
+                    file_item.saved_audio_track.value = index - 1
             else:
                 self.show_overlay_text("No audio tracks available")
 
@@ -416,15 +417,15 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
         if file_item is None or file_item.file_type == FileType.DIRECTORY:
             return
 
-        position = file_item.saved_position
-        duration = file_item.saved_duration
+        position = file_item.saved_position.value
+        duration = file_item.saved_duration.value
 
         # If position exists and is >= 99% through, clear it
         if position > 0 and (position / duration) >= 0.99:
-            file_item.saved_position = 0
+            file_item.saved_position.value = 0
         else:
             # Otherwise mark as complete
-            file_item.saved_position = duration
+            file_item.saved_position.value = duration
 
         # Force the list to update the changed item
         self.list_model.items_changed(self.selection_model.get_selected(), 1, 1)
@@ -479,23 +480,22 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
 
         position = self.video_player.get_position()
         if position is not None:
-            file_item.saved_position = position
+            file_item.saved_position.value = position
 
         duration = self.video_player.get_duration()
         if duration is not None:
-            file_item.saved_duration = duration
+            file_item.saved_duration.value = duration
 
     def show_overlay_text(self, text: str, timeout_seconds: float = 1.0) -> None:
         """Show text in a centered overlay that disappears after timeout"""
+
         self.overlay_label.set_text(text)
         self.overlay_label.set_visible(True)
 
         # Set absolute time when overlay should hide
         self.overlay_hide_time = self.now + timeout_seconds
 
-    def _sync_overlay_grid(self):
-        """Update grid visibility based on player state."""
-
+    def _watch_player_state(self):
         is_playing = self.video_player.is_playing.value
         is_paused = self.video_player.is_paused.value
         self.grid_overlay.set_visible(is_playing and is_paused)
@@ -523,8 +523,8 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
         if file_item := self.selection:
             self.thumbnailer.generate_thumbnail(file_item)
 
-            if file_item.thumbnail and not self.thumbnail_image.get_paintable():
-                gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail))
+            if file_item.thumbnail.value and not self.thumbnail_image.get_paintable():
+                gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail.value))
                 texture = Gdk.Texture.new_from_bytes(gbytes)
                 self.thumbnail_image.set_paintable(texture)
 
diff --git a/lazy_player/reactive.py b/lazy_player/reactive.py
index aae3b70..a9ea19c 100644
--- a/lazy_player/reactive.py
+++ b/lazy_player/reactive.py
@@ -81,6 +81,11 @@ class Computed(Ref[T]):
 class Watcher:
     _watches: set[Computed[Any]]
 
+    def watch_all(self, prefix: str = "_watch_"):
+        for method in dir(self):
+            if method.startswith(prefix):
+                self.watch(getattr(self, method))
+
     def watch(self, handler: Callable[[], Any]):
         if not hasattr(self, "_watches"):
             self._watches = set()