State Hoisting in Jetpack Compose: Konzepte und Praxis
Lerne, wie du durch State Hoisting in Jetpack Compose deine UI-Komponenten zustandslos, wiederverwendbar und testbar machst.
Wenn du mit Jetpack Compose arbeitest, stellst du schnell fest, dass der Umgang mit dem Zustand deiner Benutzeroberfläche entscheidend für die Qualität der Anwendung ist. Eine UI-Komponente, die ihren eigenen Zustand intern verwaltet, verhält sich oft starr und ist schwer in anderen Kontexten einzusetzen. Um dieses Problem zu lösen, greift man auf ein etabliertes Entwurfsmuster zurück, das den Datenfluss in deiner Applikation strukturiert und bereinigt. Dieses Muster entkoppelt die visuelle Darstellung von der Datenhaltung und sorgt dafür, dass deine Architektur skalierbar, wartbar und vor allem testbar bleibt.
Was ist das?
State Hoisting ist ein Entwurfsmuster in Jetpack Compose, bei dem der Zustand einer Composable-Funktion an den Aufrufer ausgelagert wird. Der Begriff “Hoisting” bedeutet wörtlich übersetzt “hochziehen” oder “anheben”. Du entfernst die interne Zustandsverwaltung aus der Komponente und machst sie stattdessen über Parameter von außen steuerbar. Dadurch verwandelst du eine zustandsbehaftete Komponente (Stateful Composable) in eine zustandslose Komponente (Stateless Composable).
In der Android-Entwicklung spricht man oft von der sogenannten “Source of Truth”, also der einzigen, verlässlichen Quelle für einen bestimmten Zustand. Wenn eine UI-Komponente ihren Zustand selbst verwaltet, kreiert sie ihre eigene lokale Source of Truth. Dies führt zu Problemen, wenn andere Teile der Benutzeroberfläche denselben Zustand benötigen oder auf Änderungen reagieren sollen. Durch das Auslagern des Zustands verschiebst du die Source of Truth nach oben in der Hierarchie, idealerweise in eine übergeordnete Composable-Funktion oder in ein ViewModel.
Eine zustandslose Komponente definiert sich dadurch, dass sie keine eigenen Variablen mit remember oder mutableStateOf hält. Sie erhält alle Informationen, die sie für die Darstellung benötigt, über ihre Parameter. Gleichzeitig teilt sie dem Aufrufer über Funktionen (Callbacks) mit, wenn eine Interaktion stattfindet. Dieses Konzept der Trennung von Daten und Darstellung ist ein Kernprinzip der deklarativen UI-Programmierung und unabdingbar für die Konstruktion komplexer, aber dennoch wartbarer Android-Applikationen.
Durch diese klare Aufgabentrennung erhöhst du die Wiederverwendbarkeit (Reuse) deiner Composables massiv. Eine Schaltfläche, die nicht selbst entscheidet, was nach dem Klick passiert, kann an beliebigen Stellen im Code für völlig unterschiedliche Aktionen verwendet werden. Zudem verbessert State Hoisting die Testbarkeit deiner UI-Schicht, da du einer zustandslosen Komponente in isolierten UI-Tests vordefinierte Werte übergeben und das Auslösen der Callbacks direkt überprüfen kannst.
Wie funktioniert es?
Die technische Umsetzung von State Hoisting in Jetpack Compose basiert auf dem Prinzip des Unidirectional Data Flow (UDF) – dem unidirektionalen Datenfluss. Dieses Prinzip besagt, dass Daten immer in eine Richtung fließen, während Ereignisse in die entgegengesetzte Richtung gemeldet werden. Konkret fließen die Zustandsdaten von der übergeordneten Komponente (dem Aufrufer) nach unten zur untergeordneten Komponente. Ereignisse, die durch Benutzerinteraktionen entstehen, fließen über Callbacks von der untergeordneten Komponente nach oben zum Aufrufer.
Um eine Komponente zustandslos zu machen, ersetzt du die internen Zustandsvariablen durch zwei spezifische Parameter. Der erste Parameter nimmt den aktuellen Wert des Zustands entgegen. Der zweite Parameter ist eine Lambda-Funktion, die aufgerufen wird, wenn sich der Zustand ändern soll. Diese Lambda-Funktion übernimmt die Rolle des Callbacks.
Betrachten wir den Lebenszyklus einer solchen Konstruktion. Wenn die übergeordnete Komponente gerendert wird, übergibt sie den initialen Zustand an die zustandslose Composable. Der Nutzer interagiert mit der Benutzeroberfläche, beispielsweise durch die Eingabe von Text in ein Textfeld. Die zustandslose Composable ändert den Text nicht selbst. Stattdessen ruft sie den Callback auf und übergibt den neuen, vom Nutzer gewünschten Text als Argument.
Die übergeordnete Komponente, die den Callback bereitgestellt hat, empfängt diese Benachrichtigung. Da sie die Source of Truth ist, entscheidet sie nun, ob und wie der Zustand aktualisiert wird. Aktualisiert sie den Zustand, erkennt Jetpack Compose diese Änderung. Dies löst eine Recomposition aus – die UI wird mit dem neuen Zustand aktualisiert und die zustandslose Composable wird mit dem geänderten Wert neu gezeichnet.
Wichtig ist zu verstehen, dass nicht jeder Zustand zwingend nach oben verlagert werden muss. Wenn eine Composable einen Zustand hat, der ausschließlich ihre interne Darstellung betrifft – wie etwa den Animationsstatus eines Buttons beim Klicken –, kann dieser Zustand intern bleiben. Sobald jedoch andere Composables den Zustand benötigen oder das übergeordnete ViewModel die Daten verarbeiten muss, ist State Hoisting der korrekte Weg.
Die Parameter, die du für State Hoisting definierst, sollten dabei so generisch wie möglich gehalten werden. Anstatt spezifische Business-Logik-Parameter zu verlangen, solltest du primitive Datentypen oder einfache Datenklassen verwenden. Die Callbacks sollten präzise benannt werden, idealerweise mit dem Präfix “on”, gefolgt von der Aktion, zum Beispiel onValueChange oder onClick.
In der Praxis
Um die Theorie greifbar zu machen, betrachten wir ein typisches Szenario aus dem Android-Entwickleralltag: die Implementierung eines Textfeldes zur Namenseingabe. Zunächst analysieren wir eine fehlerhafte, zustandsbehaftete Umsetzung, bei der die Komponente ihren eigenen Zustand verwaltet.
@Composable
fun StatefulNameInput() {
var name by remember { mutableStateOf("") }
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Dein Name") }
)
}
Diese Funktion funktioniert isoliert betrachtet, ist aber für reale Anwendungen ungeeignet. Wenn du den eingegebenen Namen an anderer Stelle benötigst, beispielsweise um ihn an ein Backend zu senden, kommst du von außen nicht an den Wert der Variablen name heran. Auch lässt sich dieses Textfeld nicht wiederverwenden, um etwa eine E-Mail-Adresse abzufragen, da das Label fest integriert ist und der Zustand intern verborgen bleibt.
Wenden wir nun State Hoisting an, um eine zustandslose, hochgradig wiederverwendbare Komponente zu erstellen. Wir identifizieren den Zustand (name) und das Ereignis (onValueChange) und ziehen diese als Parameter in die Signatur der Funktion hoch. Zusätzlich übergeben wir das Label als flexiblen String.
@Composable
fun StatelessInput(
value: String,
onValueChange: (String) -> Unit,
label: String
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) }
)
}
Jetzt ist die Funktion StatelessInput vollkommen unabhängig. Sie speichert keine eigenen Daten und verlässt sich ausschließlich auf die Parameter, die sie erhält. Sie ist leicht testbar, da du in deinen Unit- oder UI-Tests feste Strings für value übergeben und überprüfen kannst, ob onValueChange bei Texteingaben korrekt ausgelöst wird. Du könntest hierzu in einem UI-Test mit der ComposeTestRule einen simulierten Klick ausführen und sicherstellen, dass die angebundene Funktion exakt einmal mit dem erwarteten Wert gerufen wird.
Die Verwaltung des Zustands übernimmt nun eine übergeordnete Komponente oder idealerweise ein ViewModel. Das ViewModel agiert als Source of Truth für die gesamte Ansicht.
@Composable
fun UserProfileScreen(viewModel: ProfileViewModel) {
// Die Source of Truth liegt im ViewModel
val userName by viewModel.userName.collectAsState()
Column(modifier = Modifier.padding(16.dp)) {
StatelessInput(
value = userName,
onValueChange = { newName -> viewModel.updateName(newName) },
label = "Dein Name"
)
}
}
Eine häufige Stolperfalle in der Praxis ist die Übergabe von zu vielen Informationen oder die Übergabe des gesamten ViewModels an untergeordnete Composables. Wenn du das ViewModel direkt als Parameter an StatelessInput übergeben würdest, wäre die Komponente wieder stark gekoppelt. Sie würde nicht nur für die Namenseingabe funktionieren, sondern wäre abhängig von der spezifischen Implementierung des ProfileViewModel. Das limitiert die Wiederverwendbarkeit enorm.
Eine verlässliche Entscheidungsregel lautet: Übergib einer Composable immer nur die minimal benötigten Daten und keine Objekte, die mehr Logik oder Zustand enthalten als für die Darstellung dieser spezifischen Komponente erforderlich ist. Wenn eine Komponente nur einen String benötigt, übergib einen String, kein komplexes User-Objekt oder ein ViewModel.
Fazit
State Hoisting ist ein essenzielles Konzept, um Jetpack Compose effektiv und architektonisch sauber einzusetzen, indem du den Zustand von der Benutzeroberfläche entkoppelst. Du transformierst starre, zustandsbehaftete UI-Elemente in flexible, zustandslose Komponenten, was zu einer erhöhten Wiederverwendbarkeit und einer robusten, zentralen Source of Truth führt. Prüfe beim nächsten Review deines Codes aktiv, ob deine Composables eigene Zustände halten und ob sich diese auslagern lassen. Nutze UI-Tests, um das Zusammenspiel zwischen den hochgezogenen Zuständen und den von dir definierten Callbacks zu validieren, und stelle sicher, dass du keine ViewModels an tief liegende Composables durchreichst.