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:

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

  1. 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
  2. Virtualisierung der Liste (nicht gewählt)
    • Pro: Rendert nur sichtbare Elemente
    • Contra: Overkill für 193 Transaktionen, hohe Komplexität
  3. 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?

  1. Explizitheit: In F# bevorzuge ich explizite Datenflüsse. Die Signatur categoryOptions: (string * string) list macht klar, dass diese Daten von außen kommen.

  2. Testbarkeit: Die Funktion ist jetzt eine reine Funktion ohne versteckte Dependencies.

  3. 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:

  1. Gefühlte Performance: Die UI reagiert sofort - 0ms statt ~800ms
  2. Robustheit: Bei Fehlern wird ein Rollback durch Neuladen der Transaktionen durchgeführt
  3. 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

  1. 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
  2. Native <datalist> (nicht gewählt)
    • Pro: Browser-native, keine JS nötig
    • Contra: Inkonsistentes Verhalten zwischen Browsern, keine Keyboard-Navigation
  3. 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:

  1. 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)
    
  2. 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 |])
    
  3. 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

  1. Modal-Dialog (nicht gewählt)
    • Pro: Vertrautes UI-Pattern
    • Contra: Unterbricht den Flow, verliert Kontext zur Transaktion
  2. 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:

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:

Statistiken:

Key Takeaways für Neulinge

  1. Messen vor Optimieren: Chrome DevTools Performance-Tab zeigt genau, wo die Zeit verloren geht. Keine vorzeitigen Optimierungen basierend auf Vermutungen.

  2. Datenflüsse explizit machen: In funktionalen Sprachen wie F# ist es oft besser, Daten explizit durchzureichen als auf Framework-Magie (wie useMemo) zu setzen.

  3. Optimistisches UI mit Bedacht: Es verbessert die gefühlte Performance dramatisch, aber plane immer einen Rollback-Mechanismus für Fehler ein.