--- title: "Reactive Production Patterns" description: "Operational patterns for using RoomSharp reactive queries, caching, and UI bindings safely in production applications." canonical: "https://roomsharp.dev/docs/v0.5.4/reactive-production-patterns" source: "src/content/v0.5.4/reactive-production-patterns.mdx" --- # Reactive Production Patterns Reactive queries are most useful when they are predictable under load: they should refresh only when the watched data changes, coalesce bursts, keep memory bounded, and surface failures instead of hiding them. This page focuses on production usage rather than basic API shape. For the API overview, start with [Reactive Queries](/docs/v0.5.4/reactive-queries). ## Recommended Shape A production reactive screen usually has five explicit decisions: | Decision | Recommended default | |----------|---------------------| | Watched tables | Use table IDs resolved once at startup or screen construction. | | Refresh frequency | Use debounce for write bursts; use throttle only when a steady refresh rate is desired. | | Delivery behavior | Prefer `LatestOnly` for UI screens; use buffered/all delivery only when every intermediate value matters. | | Cache policy | Use TTL plus `MaxEntries`; always provide `TableIds` for invalidation. | | UI binding | Use `keySelector`, preserve instances with `mergeExisting`, and always provide `onError`. | ```csharp using RoomSharp.Extensions; using RoomSharp.Invalidation; using RoomSharp.Reactive; var db = RoomDatabase.Builder() .UseSqlite("app.db") .EnableChangeTracking(o => { o.DispatchInterval = TimeSpan.FromMilliseconds(50); o.DeliveryMode = DeliveryMode.LatestOnly; }) .Build(); var todosTable = db.GetTableIdOrThrow(); ``` `LatestOnly` is the right default for most UI screens. It prevents slow subscribers from receiving a backlog of obsolete states after a burst of writes. ## Table Selection Watch the tables that can change the query result. If the query joins multiple tables, include every table that affects the projection. ```csharp // Prefer typed lookup when the watched table belongs to a RoomSharp entity. var todos = db.GetTableIdOrThrow(); var lists = db.GetTableIdOrThrow(); // String lookup is also available for raw table names, views, or dynamic infrastructure code. // var todos = db.GetTableIdOrThrow("todos"); // var lists = db.GetTableIdOrThrow("todo_lists"); await using var query = db.Observe( async ct => await db.TodoDao.GetTodosWithListsAsync(), todos, lists); ``` Avoid watching unrelated tables. It increases refresh work and makes performance harder to reason about. ## Debounce And Throttle Use debounce when writes arrive in bursts and only the final state matters. This is common for forms, import jobs, and batch mutations. ```csharp var query = db.ObserveReactive( ct => new ValueTask>(db.TodoDao.GetAllAsync()), new ReactiveQueryOptions { DebounceInterval = TimeSpan.FromMilliseconds(150) }, todosTable); ``` Use throttle when the UI should refresh at a maximum cadence while changes continue to arrive. ```csharp var query = db.ObserveReactive( ct => new ValueTask>(db.TodoDao.GetAllAsync()), new ReactiveQueryOptions { DebounceInterval = TimeSpan.Zero, ThrottleInterval = TimeSpan.FromMilliseconds(250) }, todosTable); ``` Do not stack aggressive debounce and throttle values without measuring the user experience. High values reduce query pressure, but they can make a screen feel stale. ## Bounded Query Cache Use `QueryCache` for read-heavy reactive views where duplicate refreshes should collapse and a short TTL is acceptable. ```csharp var cache = new QueryCache(db, new QueryCacheOptions { DefaultTtl = TimeSpan.FromSeconds(20), MaxEntries = 500, FactoryCancellationMode = CacheFactoryCancellationMode.NeverCancelInFlight }); var key = db.BuildCacheKey>( "SELECT * FROM todos WHERE list_id = @listId", new object?[] { listId }); var query = db.ObserveCached( cache, key, ct => new ValueTask>(db.TodoDao.GetByListAsync(listId)), new QueryCacheEntryOptions { Ttl = TimeSpan.FromSeconds(10), TableIds = new[] { todosTable } }, options: null, todosTable); ``` `QueryCache` provides: - Single-flight execution for concurrent requests with the same key. - TTL-based expiration. - Table invalidation when constructed from a database or invalidation tracker. - A bounded entry count through `MaxEntries`. - Oldest-accessed eviction for completed entries above the configured limit. - Optional factory cancellation policy. - Metrics callbacks for hit/miss, single-flight reuse, eviction reason, active entry count, and factory duration. Use stable cache keys. Include provider, SQL shape, and all parameters that change the result. Missing a parameter in the key can return stale or incorrect data. ### Cancellation Policy The default cache behavior is intentionally conservative: ```csharp var cache = new QueryCache(db, new QueryCacheOptions { FactoryCancellationMode = CacheFactoryCancellationMode.NeverCancelInFlight }); ``` With `NeverCancelInFlight`, one cancelled waiter does not cancel the shared factory. Other subscribers can still receive the result and the cache can still store it. This is usually the right behavior for UI screens where users may navigate quickly while another view still needs the same data. Use `LinkedToFirstCaller` when the first caller owns the work and cancellation should abandon the shared factory: ```csharp var cache = new QueryCache(db, new QueryCacheOptions { FactoryCancellationMode = CacheFactoryCancellationMode.LinkedToFirstCaller }); ``` ### Cache Metrics Use `IQueryCacheMetrics2` when you want operational counters or tracing-friendly timings: ```csharp sealed class CacheMetrics : IQueryCacheMetrics2 { public void OnHit(QueryCacheKey key) => Counters.Hits.Add(1); public void OnMiss(QueryCacheKey key) => Counters.Misses.Add(1); public void OnSingleFlight(QueryCacheKey key) => Counters.SingleFlight.Add(1); public void OnEvicted(QueryCacheKey key) => Counters.Evicted.Add(1); public void OnEvicted(QueryCacheKey key, QueryCacheEvictionReason reason) => Counters.EvictedByReason[reason].Add(1); public void OnFactoryCompleted(QueryCacheKey key, TimeSpan duration) => Histograms.FactoryDuration.Record(duration.TotalMilliseconds); public void OnFactoryFailed(QueryCacheKey key, TimeSpan duration, Exception exception) => Counters.FactoryFailures.Add(1); public void OnActiveEntriesChanged(int activeEntries) => Gauges.ActiveEntries.Set(activeEntries); } ``` This keeps cache observability outside of the data access code. The cache itself does not choose a metrics backend. ## Error Handling Reactive pipelines should not fail silently. Always provide an error callback at the subscription or binding boundary. ```csharp using var sub = query.Subscribe( todos => Render(todos), error => logger.LogError(error, "Todo reactive query failed")); ``` For collection binding: ```csharp var collection = new ReactiveObservableCollection(); var ui = SynchronizationContext.Current!; using var sub = query .AsObservable() .BindToObservableCollection( collection, new ReactiveCollectionBindingOptions { Dispatch = action => ui.Post(_ => action(), null), KeySelector = todo => todo.Id, UpdateMode = ReactiveCollectionUpdateMode.Reset, MergeExisting = (existing, incoming) => { existing.Title = incoming.Title; existing.IsDone = incoming.IsDone; }, OnError = error => logger.LogError(error, "Todo binding failed") }); ``` The `onError` callback reports both source stream errors and binding update failures such as exceptions thrown by `mergeExisting`. `ReactiveObservableCollection` is useful for larger result sets because `ReplaceAll` and `MergeByKey` emit a single reset notification instead of one notification per item. Use normal `ObservableCollection` when item-level notifications are more important than reducing UI update events. ## WinForms Binding For WinForms, prefer the control-based overloads. They dispatch to the UI thread and dispose the subscription when the control is disposed. ```csharp using RoomSharp.Reactive.WinForms; using var sub = query .AsObservable() .BindToDataGridView( dataGridView1, new WinFormsBindingOptions { KeySelector = todo => todo.Id, UpdateMode = ReactiveCollectionUpdateMode.MergeByKey, MergeExisting = (existing, incoming) => { existing.Title = incoming.Title; existing.IsDone = incoming.IsDone; }, SuspendBindingDuringUpdate = true, PreserveCurrentRow = true, PreserveScrollPosition = true, PreserveSort = true, PreserveFilter = true, AutoDisposeWithControl = true, OnError = error => logger.LogError(error, "Grid binding failed") }); ``` `keySelector` lets the binding preserve existing object instances where possible. This reduces churn and helps UI controls keep row state, current row, and scroll position more predictably. `BindToDataGridView` captures and restores basic grid state around updates. For complex grids with custom sorting, filtering, or virtual mode, keep the binding small and own the advanced grid behavior explicitly. For very large grids, prefer WinForms virtual mode and treat the reactive query as the data snapshot provider. ## Diagnostics RoomSharp emits `ActivitySource` spans for reactive refresh, cache factories, and binding updates. Attach your tracing backend to `RoomSharp.Reactive`: ```csharp using System.Diagnostics; using RoomSharp.Reactive; using var listener = new ActivityListener { ShouldListenTo = source => source.Name == ReactiveDiagnostics.ActivitySourceName, Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded }; ActivitySource.AddActivityListener(listener); ``` The spans intentionally use operational tags instead of application data: | Span | Useful tags | |------|-------------| | `roomsharp.reactive.refresh` | table count, target version, concurrency mode, subscriber count | | `roomsharp.reactive.query_cache.factory` | cache key hash, TTL, table count, active entries, factory duration | | `roomsharp.reactive.binding.update` | target collection, item count, update mode | | `roomsharp.reactive.winforms.binding.update` | binding target, item count, update mode | Use these spans to answer production questions such as: how long refresh factories take, whether cache factories are being reused, whether bindings are processing large result sets, and whether subscriber updates are being dropped. ## Computed Views Use `ComputedView` to derive values from existing reactive sources instead of running additional database queries for every small aggregate. ```csharp var todosQuery = db.ObserveReactive( ct => new ValueTask>(db.TodoDao.GetAllAsync()), todosTable); var stats = ComputedView.Combine( todosQuery, todos => new TodoStats( Total: todos.Count, Done: todos.Count(todo => todo.IsDone)), new ComputedViewOptions { DebounceInterval = TimeSpan.FromMilliseconds(100), DeliveryMode = DeliveryMode.LatestOnly }); ``` Use database queries for aggregates that are large, indexed, or filtered by the provider. Use computed views when the source data is already loaded for the screen. ## Transaction Behavior RoomSharp defers invalidation until a transaction commits. This prevents observers from rendering intermediate states inside a transaction. ```csharp await db.RunInTransactionAsync(async () => { await db.TodoDao.UpdateAsync(todo); await db.AuditDao.InsertAsync(auditRow); }); // Observers refresh after commit, not after the first update inside the transaction. ``` This behavior is important for screens that observe multiple related tables. Keep logical write sets inside one transaction when observers should see them as one state change. ## Operational Checklist Before using a reactive screen in production, check the following: - The database builder enables change tracking. - The query watches every table that affects its result and no unrelated tables. - Burst-heavy screens use an explicit debounce interval. - UI screens use `LatestOnly` unless intermediate values matter. - Cache entries have a bounded `MaxEntries` and table-aware invalidation. - Every subscription or binding has an error callback. - UI bindings use `keySelector` for stable rows and `mergeExisting` when preserving instances matters. - Long-running forms dispose subscriptions or use control-based WinForms overloads. - Raw SQL writes call manual notification methods when they bypass generated DAO invalidation. ## Common Anti-Patterns | Anti-pattern | Why it hurts | Prefer | |--------------|--------------|--------| | Watching every table for one screen | Causes unnecessary refreshes. | Watch the exact tables used by the query. | | No `onError` handler | Failures become invisible. | Log or display errors at the binding boundary. | | Unbounded cache keys | Memory can grow for long-running apps. | Set `MaxEntries` and stable key shapes. | | `SELECT` refresh after every write burst | UI and database can do redundant work. | Debounce or throttle based on workload. | | Replacing UI rows without keys | Loses selection and object identity. | Use `keySelector` and `mergeExisting`. | | Mixing raw writes with no notifications | Observers do not know data changed. | Use generated DAOs or manual notifications. | ## When Not To Use Reactive Queries Reactive queries are not always the best tool. Prefer direct queries or explicit refresh buttons when: - The data changes rarely and the screen is not long-lived. - The query is very expensive and should only run after a user action. - The UI needs paged server-side navigation rather than full-list refreshes. - External systems mutate the database without reliable notification hooks. In those cases, use normal DAO methods, paging, or QueryExtensions and call refresh explicitly when the application knows the data is stale.