UI Performance Revolution: Von 15 Sekunden auf 18ms und eine neue SearchableSelect-Komponente
In dieser intensiven Arbeitssession habe ich die User Experience von BudgetBuddy grundlegend verbessert. Was als einfache “die Kategorie-Auswahl ist langsam”-Beschwerde begann, wurde zu einer umfassenden Überarbeitung: eine 872-fache Performance-Verbesserung, eine komplett neue SearchableSelect-Komponente mit vollständiger Keyboard-Navigation, und ein Inline-Rule-Creation-Workflow, der die Kategorisierung von Transaktionen revolutioniert.
Ausgangslage
BudgetBuddy ist eine Self-Hosted Single-User-App zur Synchronisation von Comdirect-Transaktionen nach YNAB (You Need A Budget). Die zentrale Funktion ist ein Sync-Flow, in dem Benutzer importierte Transaktionen kategorisieren, bevor sie nach YNAB exportiert werden.
Das Problem: Bei 193 Transaktionen und 160 Kategorien war die Kategorie-Selectbox unbenutzbar langsam. Das Öffnen eines einzigen Dropdowns dauerte über 15 Sekunden - eine Ewigkeit für eine UI-Interaktion.
Herausforderung 1: Die 872x Performance-Katastrophe
Das Problem
Beim Profiling mit den Chrome DevTools zeigte sich das Ausmaß des Problems:
- 15.700ms zum Öffnen einer einzigen Kategorie-Selectbox
- Die CPU war vollständig ausgelastet
- Die Seite war während dieser Zeit komplett eingefroren
Die Ursache fand ich in der transactionRow-Funktion:
// VORHER: Für JEDE Transaktion wurden die Kategorie-Options neu berechnet
let transactionRow tx categories dispatch =
let categoryOptions =
categories
|> List.map (fun c ->
c.Id.ToString(), $"{c.GroupName}: {c.Name}")
// ... Rest der Komponente
Bei 193 Transaktionen und 160 Kategorien bedeutete das: 30.880 String-Operationen pro Render. Und beim Öffnen eines Dropdowns wurde die gesamte Liste neu gerendert.
Optionen, die ich betrachtet habe
- React.memo / useMemo (nicht gewählt)
- Pro: React’s eingebaute Memoization
- Contra: In Feliz/F# nicht so natürlich zu verwenden, außerdem behandelt es nur das Symptom
- Virtualisierung der Liste (nicht gewählt)
- Pro: Rendert nur sichtbare Elemente
- Contra: Overkill für 193 Transaktionen, hohe Komplexität
- Vorberechnung der Options (gewählt)
- Pro: Einfach, effektiv, löst das Problem an der Wurzel
- Contra: Erfordert Änderung der Funktionssignatur
Die Lösung
Die Kategorie-Options werden jetzt einmal vor der Schleife berechnet und als Parameter durchgereicht:
// NACHHER: Options werden einmal berechnet und übergeben
let private transactionRow
(tx: SyncTransaction)
(categoryOptions: (string * string) list) // Vorberechnet!
(expandedIds: Set<TransactionId>)
(dispatch: Msg -> unit) =
// categoryOptions wird direkt verwendet, keine Berechnung mehr
// Im transactionListView:
let categoryOptions =
categories
|> List.map (fun c ->
(c.Id |> fun (YnabCategoryId id) -> id.ToString()),
$"{c.GroupName}: {c.Name}")
// Einmalig berechnet, 193x wiederverwendet
transactions
|> List.map (fun tx -> transactionRow tx categoryOptions expandedIds dispatch)
Ergebnis: | Metrik | Vorher | Nachher | Verbesserung | |——–|——–|———|————–| | Dropdown öffnen | 15.700ms | 18ms | 872x |
Architekturentscheidung: Warum Parameter statt useMemo?
-
Explizitheit: In F# bevorzuge ich explizite Datenflüsse. Die Signatur
categoryOptions: (string * string) listmacht klar, dass diese Daten von außen kommen. -
Testbarkeit: Die Funktion ist jetzt eine reine Funktion ohne versteckte Dependencies.
-
F#-Idiomatik: Statt auf React-Hooks zu setzen, nutze ich F#’s funktionale Stärken - Daten fließen nach unten durch die Komponentenhierarchie.
Herausforderung 2: Pessimistisches vs. Optimistisches UI
Das Problem
Nach der Performance-Optimierung war das Dropdown schnell - aber die Kategorie-Auswahl selbst fühlte sich immer noch träge an. Nach dem Klick auf eine Kategorie dauerte es fast eine Sekunde, bis sie angezeigt wurde.
Der Grund war “pessimistisches UI”: Das Model wurde erst aktualisiert, nachdem der API-Call zurückkam:
// VORHER: Pessimistisch - warte auf Server
| CategorizeTransaction (txId, categoryId) ->
// Nur API-Call starten, Model nicht ändern
let cmd = Cmd.OfAsync.either Api.sync.categorizeTransaction ...
model, cmd, NoOp
| TransactionCategorized (Ok updatedTx) ->
// ERST HIER wird das Model aktualisiert
let newTxs = transactions |> List.map (fun tx ->
if tx.Transaction.Id = updatedTx.Transaction.Id then updatedTx else tx)
{ model with SyncTransactions = Success newTxs }, Cmd.none, NoOp
Die Lösung: Optimistisches UI
Bei optimistischem UI wird das Model sofort aktualisiert, und der API-Call läuft im Hintergrund:
// NACHHER: Optimistisch - sofort aktualisieren
| CategorizeTransaction (txId, categoryId) ->
match model.CurrentSession, model.SyncTransactions with
| Success (Some session), Success transactions ->
// SOFORT das Model aktualisieren
let updatedTransactions =
transactions |> List.map (fun tx ->
if tx.Transaction.Id = txId then
let categoryName =
categoryId |> Option.bind (fun catId ->
model.Categories |> List.tryFind (fun c -> c.Id = catId))
|> Option.map (fun c -> $"{c.GroupName}: {c.Name}")
let newStatus =
match tx.Status, categoryId with
| Skipped, _ -> Skipped
| _, Some _ -> ManualCategorized
| _, None -> Pending
{ tx with
CategoryId = categoryId
CategoryName = categoryName
Status = newStatus
Splits = None }
else tx)
// API-Call im Hintergrund
let cmd = Cmd.OfAsync.either Api.sync.categorizeTransaction ...
{ model with SyncTransactions = Success updatedTransactions }, cmd, NoOp
Rationale für Optimistisches UI:
- Gefühlte Performance: Die UI reagiert sofort - 0ms statt ~800ms
- Robustheit: Bei Fehlern wird ein Rollback durch Neuladen der Transaktionen durchgeführt
- Angemessenes Risiko: Kategorie-Änderungen sind unkritisch. Ein seltener Rollback ist akzeptabel.
Was passiert bei Fehlern?
| TransactionCategorized (Error err) ->
// Rollback: Transaktionen vom Server neu laden
model, Cmd.ofMsg LoadTransactions, ShowToast (syncErrorToString err, ToastError)
Die Transactions werden einfach neu vom Server geladen - der korrekte Zustand wird wiederhergestellt, und der User sieht einen Toast mit der Fehlermeldung.
Herausforderung 3: Die SearchableSelect-Komponente
Das Problem
160 Kategorien in einem normalen <select>-Dropdown sind unübersichtlich. Benutzer müssen scrollen und visuell nach der richtigen Kategorie suchen. Die Lösung: Eine durchsuchbare Selectbox wie man sie von modernen UI-Libraries kennt.
Optionen, die ich betrachtet habe
- Externe Library (react-select) (nicht gewählt)
- Pro: Feature-komplett, getestet
- Contra: NPM-Dependency, Styling-Konflikte mit unserem Design-System, schwer in Feliz zu integrieren
- Native
<datalist>(nicht gewählt)- Pro: Browser-native, keine JS nötig
- Contra: Inkonsistentes Verhalten zwischen Browsern, keine Keyboard-Navigation
- Custom React-Komponente (gewählt)
- Pro: Volle Kontrolle, perfekte Integration mit Design-System
- Contra: Mehr Implementierungsaufwand
Die Implementierung
Die SearchableSelect-Komponente ist eine React-Funktionskomponente mit Feliz:
[<ReactComponent>]
let SearchableSelect (props: SearchableSelectProps) =
// State
let isOpen, setIsOpen = React.useState false
let searchText, setSearchText = React.useState ""
let highlightedIndex, setHighlightedIndex = React.useState -1
let isKeyboardNav, setIsKeyboardNav = React.useState false
// Refs für DOM-Zugriff
let containerRef = React.useRef<Browser.Types.HTMLElement option> None
let inputRef = React.useRef<Browser.Types.HTMLInputElement option> None
let listRef = React.useRef<Browser.Types.HTMLElement option> None
Kernfeatures:
- Case-insensitive Contains-Filter:
let filteredOptions = if System.String.IsNullOrWhiteSpace searchText then props.Options else let searchLower = searchText.ToLowerInvariant() props.Options |> List.filter (fun (_, label) -> label.ToLowerInvariant().Contains searchLower) - Click-outside-Detection:
React.useEffect (fun () -> let handleClickOutside (e: Browser.Types.Event) = match containerRef.current with | Some container -> let target = e.target :?> Browser.Types.HTMLElement if not (container.contains target) then setIsOpen false setSearchText "" | None -> () Browser.Dom.document.addEventListener("mousedown", handleClickOutside) { new System.IDisposable with member _.Dispose() = Browser.Dom.document.removeEventListener("mousedown", handleClickOutside) } , [| isOpen :> obj |]) - Vollständige Keyboard-Navigation:
let handleKeyDown (e: Browser.Types.KeyboardEvent) = match e.key with | "Escape" -> e.preventDefault() setIsOpen false | "ArrowDown" -> e.preventDefault() setIsKeyboardNav true let nextIndex = if highlightedIndex < totalItems - 1 then highlightedIndex + 1 else 0 // Wrap to top setHighlightedIndex nextIndex | "ArrowUp" -> e.preventDefault() setIsKeyboardNav true let nextIndex = if highlightedIndex > 0 then highlightedIndex - 1 else totalItems - 1 // Wrap to bottom setHighlightedIndex nextIndex | "Enter" -> e.preventDefault() if highlightedIndex >= 0 then selectOption highlightedIndex elif filteredOptions.Length = 1 then selectOption 1 // Auto-select single match | "Tab" -> setIsOpen false | _ -> ()
Der Scroll-Bug: Maus vs. Tastatur
Hier stieß ich auf ein subtiles Problem: Wenn der User mit der Maus über Optionen hoverte, scrollte das gesamte Modal/Fenster - nicht nur die Dropdown-Liste.
Root Cause: Ich hatte scrollIntoView() verwendet, das den gesamten Viewport scrollt. Bei Mouse-Hover wurde diese Funktion bei jedem onMouseEnter aufgerufen.
Die Lösung: Ein isKeyboardNav-State unterscheidet zwischen Maus- und Tastatur-Navigation:
// Nur bei Keyboard-Navigation scrollen
React.useEffect (fun () ->
if highlightedIndex >= 0 && isKeyboardNav then
match listRef.current with
| Some list ->
let items = list.querySelectorAll("[data-option-index]")
if highlightedIndex < int items.length then
let item = items.[highlightedIndex] :?> Browser.Types.HTMLElement
// Manuelles Scrollen NUR innerhalb der Liste
let itemTop = item.offsetTop
let itemHeight = item.offsetHeight
let listScrollTop = list.scrollTop
let listHeight = list.clientHeight
if itemTop < listScrollTop then
list.scrollTop <- itemTop
elif itemTop + itemHeight > listScrollTop + listHeight then
list.scrollTop <- itemTop + itemHeight - listHeight
| None -> ()
, [| highlightedIndex :> obj; isKeyboardNav :> obj |])
// Bei Mouse-Events: isKeyboardNav = false
let setHighlightFromMouse index =
setIsKeyboardNav false
setHighlightedIndex index
Architekturentscheidung: Statt scrollIntoView() berechne ich manuell list.scrollTop. Das scrollt nur die Dropdown-Liste, nicht das umgebende Modal oder die Seite.
Herausforderung 4: Inline Rule Creation
Das Problem
Ein häufiger Workflow: User kategorisiert eine Transaktion manuell, dann will er eine Regel erstellen, damit ähnliche Transaktionen automatisch kategorisiert werden. Bisher musste man dafür in den Rules-Bereich navigieren, alle Felder manuell ausfüllen, und wieder zurück.
Die Idee
Direkt nach dem Kategorisieren einer Transaktion erscheint ein “Create Rule”-Button. Ein Klick expandiert ein Inline-Formular unter der Transaktion, pre-filled mit den Daten dieser Transaktion.
Optionen, die ich betrachtet habe
- Modal-Dialog (nicht gewählt)
- Pro: Vertrautes UI-Pattern
- Contra: Unterbricht den Flow, verliert Kontext zur Transaktion
- Inline-Expansion (gewählt)
- Pro: Bleibt im Kontext, keine Unterbrechung, schneller Workflow
- Contra: Komplexere State-Verwaltung
Die Implementierung
1. State-Erweiterung im Model:
type InlineRuleFormState = {
TransactionId: TransactionId
Name: string
Pattern: string
PatternType: PatternType
TargetField: TargetField
CategoryId: YnabCategoryId option
CategoryName: string option
IsSaving: bool
}
type Model = {
// ... andere Felder
InlineRuleForm: InlineRuleFormState option
ManuallyCategorizedIds: Set<TransactionId> // Trackt welche manuell kategorisiert wurden
}
2. “Create Rule” Button-Logik:
Der Button erscheint nur für Transaktionen, die:
- Manuell kategorisiert wurden (nicht durch Rules)
- Eine Kategorie haben
- Nicht übersprungen wurden
let createRuleButton tx manuallyCategorizedIds dispatch =
// Nur zeigen wenn manuell kategorisiert
let showButton =
manuallyCategorizedIds.Contains tx.Transaction.Id
&& tx.CategoryId.IsSome
&& tx.Status <> Skipped
if showButton then
Html.button [
prop.className "btn btn-xs btn-ghost text-neon-purple"
prop.onClick (fun _ -> dispatch (OpenInlineRuleForm tx.Transaction.Id))
prop.children [ Icons.cog Icons.XS Icons.NeonPurple ]
]
else
// Platzhalter für konsistentes Layout
Html.div [ prop.className "w-6" ]
3. Pre-filling des Formulars:
Beim Öffnen wird das Formular mit sinnvollen Defaults gefüllt:
| OpenInlineRuleForm txId ->
match model.SyncTransactions with
| Success transactions ->
transactions
|> List.tryFind (fun tx -> tx.Transaction.Id = txId)
|> Option.map (fun tx ->
let payee = tx.Transaction.Payee |> Option.defaultValue ""
{
TransactionId = txId
Name = $"Auto: {payee}"
Pattern = payee // Payee als Pattern
PatternType = Contains // Default: Contains-Match
TargetField = Combined // Default: Payee + Memo
CategoryId = tx.CategoryId
CategoryName = tx.CategoryName
IsSaving = false
})
|> fun form -> { model with InlineRuleForm = form }, Cmd.none, NoOp
4. Auto-Apply nach dem Speichern:
Das Beste: Nach dem Erstellen einer Regel wird sie sofort auf alle passenden Transaktionen angewandt:
| InlineRuleSaved (Ok savedRule) ->
// Schließe das Formular
let updatedModel = { model with InlineRuleForm = None }
// Finde alle Transaktionen, auf die die neue Regel passt
match model.SyncTransactions with
| Success transactions ->
let matchingTxIds =
transactions
|> List.filter (fun tx ->
tx.Status = Pending
&& tx.CategoryId.IsNone
&& matchesRule savedRule tx.Transaction)
|> List.map (fun tx -> tx.Transaction.Id)
if matchingTxIds.IsEmpty then
updatedModel, Cmd.none, ShowToast ("Rule created!", ToastSuccess)
else
// API-Call zum Anwenden der Regel
let cmd = Cmd.OfAsync.either
Api.rules.applyRuleToTransactions
(savedRule.Id, matchingTxIds) ...
updatedModel, cmd, ShowToast ($"Rule created! Applying to {matchingTxIds.Length} transactions...", ToastSuccess)
Rationale für Auto-Apply:
Wenn ein User eine Regel erstellt, ist der häufigste nächste Schritt: “Wende diese Regel auf ähnliche Transaktionen an.” Durch Auto-Apply spare ich diesen Schritt und der User sieht sofort, wie viele Transaktionen automatisch kategorisiert wurden.
Herausforderung 5: Zusätzliche Performance-Optimierungen
Skipped Transactions ohne Selectbox
Eine weitere Optimierung: Für übersprungene Transaktionen wird keine interaktive Selectbox gerendert, sondern nur ein statischer Text:
if tx.Status = Skipped then
// Skipped: nur Text (schnell)
Html.span [
prop.className "text-sm text-base-content/50 truncate"
prop.text (categoryText tx.CategoryId categoryOptions)
]
else
// Aktiv: Selectbox (interaktiv)
Input.searchableSelect ...
Rationale: Die SearchableSelect-Komponente hat viele Event-Handler, State, und DOM-Nodes. Bei 50% übersprungenen Transaktionen bedeutet das 50% weniger komplexe Komponenten im DOM.
Category Text Lookup
Eine Hilfsfunktion um den Kategorienamen aus den vorberechneten Options zu finden:
let categoryText (categoryId: YnabCategoryId option) (categoryOptions: (string * string) list) =
categoryId
|> Option.bind (fun (YnabCategoryId id) ->
categoryOptions
|> List.tryFind (fun (v, _) -> v = id.ToString())
|> Option.map snd)
|> Option.defaultValue "No category"
Lessons Learned
1. Performance-Profiling zuerst
Meine initiale Vermutung war, dass die SearchableSelect-Komponente selbst langsam sei. Erst das Chrome DevTools Profiling zeigte, dass das Problem in der wiederholten Berechnung der Options lag - nicht im Rendering selbst.
Takeaway: Nicht raten, messen. DevTools sind dein Freund.
2. Explizite Datenflüsse in F#
Statt auf React-Memoization zu setzen, habe ich das Problem durch explizite Datenübergabe gelöst. Das ist idiomatischer F#-Code und leichter zu verstehen.
3. Keyboard vs. Mouse State
Der Scroll-Bug hat mich eine Stunde gekostet. Die Lösung - ein separater isKeyboardNav State - war elegant, aber nicht offensichtlich. Bei UI-Komponenten muss man Maus- und Tastatur-Interaktion oft getrennt behandeln.
4. Optimistisches UI braucht Rollback-Strategie
Optimistisches UI fühlt sich großartig an, aber man muss auch an Fehler denken. Meine Lösung - einfach alles neu laden - ist simpel aber effektiv für diese Use Case.
Fazit
Diese Session hat die User Experience von BudgetBuddy dramatisch verbessert:
| Feature | Vorher | Nachher |
|---|---|---|
| Dropdown öffnen | 15.700ms | 18ms |
| Kategorie auswählen | ~800ms | sofort |
| Kategorie suchen | Unmöglich | Contains-Filter |
| Keyboard-Navigation | Keine | Vollständig |
| Regel erstellen | 5+ Klicks | 2 Klicks, inline |
Geänderte Dateien:
src/Client/Components/SyncFlow/View.fs- Komplett überarbeitete Transaction-Rowsrc/Client/Components/SyncFlow/State.fs- Optimistisches UI, Inline-Rule-Handlingsrc/Client/Components/SyncFlow/Types.fs- Neue Types für Inline-Rule-Formsrc/Client/DesignSystem/Input.fs- Neue SearchableSelect-Komponente
Statistiken:
- Build: Erfolgreich
- Tests: 279/285 bestanden (6 Integration-Tests übersprungen)
- Performance: 872x Verbesserung
Key Takeaways für Neulinge
-
Messen vor Optimieren: Chrome DevTools Performance-Tab zeigt genau, wo die Zeit verloren geht. Keine vorzeitigen Optimierungen basierend auf Vermutungen.
-
Datenflüsse explizit machen: In funktionalen Sprachen wie F# ist es oft besser, Daten explizit durchzureichen als auf Framework-Magie (wie useMemo) zu setzen.
-
Optimistisches UI mit Bedacht: Es verbessert die gefühlte Performance dramatisch, aber plane immer einen Rollback-Mechanismus für Fehler ein.