All examples in Kotlin + Jetpack Compose, but the ideas carry to React, SwiftUI, Flutter, Svelte — any declarative UI.
The one idea everything hangs on
In the old (imperative) way, you told the screen how to change, step by step: “set this label, hide that spinner, now show the list.” You were the one keeping the screen correct, by hand, forever.
In the declarative way, you flip it around. You describe what the screen should look like for a given set of data, and the framework redraws it for you whenever that data changes. You never touch the screen directly again.
UI = f(state)
Read it out loud: the user interface is a function of state. Give it the same data, you get the same screen. Change the data, the screen follows automatically.
Everything below is about one question: what is that “state,” and how do you shape it so bugs become impossible instead of merely unlikely?
We’ll build up in three layers:
- Core — how to model simple, local data (rules 0–6).
- Real-world — what changes when data comes from a server, moves over time, or lives outside your screen (rules 7–12).
- Free wins — nice things you get for free once the core is right (rules 13–14).
Part 1 — The Core
Rule 0: The screen is a function of state
Don’t reach into a widget and change it. Change the data, and let the screen redraw itself.
The slow, fragile way — you update the data and the widget, and you must never forget either:
fun increment() {
count = count + 1
counterTextView.text = count.toString() // forget this line → screen lies
}
The declarative way — there’s only data. The screen is just a description of it:
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text(count.toString()) // always matches count, automatically
}
Takeaway: You change facts. The screen is downstream. You never paint pixels by hand.
Rule 1: Store the smallest set of facts. Derive the rest.
Some data is a fact — a user typed it, a server sent it, you can’t compute it from anything else. Other data is derived — you can calculate it from the facts.
Never store what you can derive. The moment you store two things that should agree, they will eventually disagree. That’s called drift, and it’s the source of a huge share of UI bugs.
Bad — fullName is stored, so now you have to remember to update it every single time the first or last name changes:
var firstName by mutableStateOf("Ada")
var lastName by mutableStateOf("Lovelace")
var fullName by mutableStateOf("Ada Lovelace") // stale the instant a name changes
Good — fullName is computed on demand, so it can’t be wrong:
var firstName by mutableStateOf("Ada")
var lastName by mutableStateOf("Lovelace")
val fullName by remember {
derivedStateOf { "$firstName $lastName" } // always correct, nothing to sync
}
A quick test for every piece of state you’re about to add: “Can I calculate this from something I already have?” If yes, don’t store it.
Takeaway: Two copies of the same truth will drift. Keep one truth, compute the rest.
Rule 2: Make illegal states impossible to represent
This is the highest-leverage idea in the whole guide.
Think about a screen that loads a list. People reach for three separate flags: isLoading, error, data. But now ask yourself: what does it mean if isLoading is true and error is set and data is full? Nothing. It’s nonsense. Yet your types allow it, so somewhere, someday, your code will produce it — and then your screen shows a spinner on top of an error on top of stale data.
The fix: design the type so the nonsense literally cannot be written.
Bad — every combination of these three is “valid” to the compiler, including the impossible ones:
data class ScreenState(
val loading: Boolean,
val error: String?,
val data: List<Item>?
)
Good — a sealed type says “the state is exactly one of these, never a mix”:
sealed interface ScreenState {
data object Loading : ScreenState
data class Error(val message: String) : ScreenState
data class Loaded(val data: List<Item>) : ScreenState
}
Now you can be loading, or errored, or loaded — never two at once. And when you handle the state with a when, the compiler forces you to cover every case. Whole categories of bugs simply stop existing.
The mental move: when you have a few flags, ask “can these be true at the same time? Should they?” If the answer is “no,” collapse them into a union (a sealed type) instead of separate booleans.
Takeaway: A bug you can’t even write down is a bug you’ll never ship.
Rule 3: Every fact has one owner, at the lowest shared point
A single piece of state must live in exactly one place. If two components each keep their own copy, those copies will disagree.
Where should it live? At the lowest common ancestor of everything that needs it — high enough that everyone who needs it can reach it, but no higher. Keeping it as local as possible keeps the rest of your app simple.
Bad — the parent has a query, and so does the child. Two sources of truth for one fact:
@Composable
fun SearchScreen() {
var query by remember { mutableStateOf("") } // parent's copy
SearchBar()
}
@Composable
fun SearchBar() {
var query by remember { mutableStateOf("") } // a SECOND, duplicate copy
TextField(value = query, onValueChange = { query = it })
}
Good — the parent owns it once; the child just receives it and reports changes back up (this is called state hoisting):
@Composable
fun SearchScreen() {
var query by remember { mutableStateOf("") }
SearchBar(query = query, onQueryChange = { query = it })
}
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
TextField(value = query, onValueChange = onQueryChange)
}
Takeaway: One fact, one owner, placed as low as sharing allows.
Rule 4: Data flows down, events flow up
Notice the shape of the “good” example above. The child got two things: a value (data flowing down) and a callback (an event flowing up). The child never changed the parent’s state itself — it just announced “the user typed something,” and the parent decided what to do.
This one-directional flow is what makes a declarative app easy to reason about. Data always travels the same way, so when something is wrong you always know which direction to look.
Bad — the child is handed the parent’s mutable state and writes to it directly. Now anyone, anywhere, can change the truth, and you can’t tell who did:
@Composable
fun NameField(state: MutableState<String>) {
TextField(
value = state.value,
onValueChange = { state.value = it } // child secretly controls parent's truth
)
}
Good — data comes down as a plain value, changes go up as an event. The child can’t mutate anything; it can only ask:
@Composable
fun NameField(value: String, onChange: (String) -> Unit) {
TextField(
value = value,
onValueChange = onChange // child reports; parent decides
)
}
A related part of this rule: keep your “change the state” logic pure and in one place (in Compose, that often means a single reducer or ViewModel function), and keep side effects (network calls, logging, timers) out of the drawing code. The screen’s job is to describe; effects belong elsewhere (see Rule 11).
Takeaway: Values go down, events go up. One direction. Always.
Rule 5: Recompute only what actually depends on what changed
Declarative frameworks redraw when state changes. If you do expensive work directly inside the drawing code, you pay that cost on every redraw — even redraws triggered by something completely unrelated.
The fix is to memoize: tell the framework exactly which inputs the computation depends on, so it only re-runs when those specific inputs change.
Bad — this filter runs on every recomposition, even when neither the list nor the query changed:
@Composable
fun ItemList(items: List<Item>, query: String) {
val visible = items.filter { it.name.contains(query) } // wasteful, runs every time
Column { visible.forEach { Text(it.name) } }
}
Good — remember(items, query) plus derivedStateOf means the filter only re-runs when items or query truly change:
@Composable
fun ItemList(items: List<Item>, query: String) {
val visible by remember(items, query) {
derivedStateOf { items.filter { it.name.contains(query) } }
}
Column { visible.forEach { Text(it.name) } }
}
The same instinct applies to lists: give each row a stable key (its id) so that when one item changes, the framework updates only that row instead of rebuilding the whole list. And isolate a fast-changing field (like a text input) so its rapid updates don’t drag the rest of the screen along with it.
Takeaway: Read exactly what you need, recompute only when those exact inputs change.
Rule 6: Compose the shapes into one nested state
By now you have small, well-shaped facts. The last core step is to nest them into a single object that describes the whole screen — instead of leaving a dozen loose variables floating around that can quietly contradict each other.
Bad — five independent variables. Nothing connects them, so nothing stops them from disagreeing:
var user by mutableStateOf<User?>(null)
var loading by mutableStateOf(false)
var error by mutableStateOf<String?>(null)
var tab by mutableStateOf(0)
var items by mutableStateOf<List<Item>>(emptyList())
Good — one screen state, with the unions from Rule 2 nested inside it:
data class HomeState(
val user: UserState, // Guest | Authed (a sealed type)
val content: ScreenState, // Loading | Error | Loaded
val selectedTab: Tab
)
Now the entire screen is described by one value. You can pass it around, snapshot it, log it, and test it as a single thing.
Takeaway: One screen, one state object — unions inside, simple flags beside.
That’s the core. Rules 0–6 fully describe local, synchronous state — data your screen owns and changes instantly. Most tutorials stop here. But real apps have data that comes from far away, changes over time, and lives in places your screen doesn’t control. That’s Part 2.
Part 2 — The Real World
Rule 7: Server state is a different animal from client state
Here’s the trap. Data from a server feels like just another variable, so people store it in mutableStateOf and move on. But server data is fundamentally different: it’s a cache of something you don’t own. The real value lives on the server and can change without telling you.
That means server data drags along a whole set of concerns local data never has: Is it loading? Did it fail? Is the cached copy stale? Should we refetch? Are two screens asking for the same thing (can we de-duplicate)? What about retries and optimistic updates?
Trying to hand-roll all of that with plain booleans is where apps go to die.
Bad — manual flags that handle the happy path and nothing else:
var todos by mutableStateOf<List<Todo>>(emptyList())
var loading by mutableStateOf(false)
fun load() {
loading = true
scope.launch {
todos = api.getTodos() // no error handling, no staleness, no retry, no caching
loading = false
}
}
Good — model the remote data as a typed Resource and expose it as a stream from a repository, so loading/error/success are baked into the type and lifecycle is handled for you:
sealed interface Resource<out T> {
data object Loading : Resource<Nothing>
data class Error(val message: String) : Resource<Nothing>
data class Success<T>(val value: T) : Resource<T>
}
val todos: StateFlow<Resource<List<Todo>>> =
repository.observeTodos()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = Resource.Loading
)
In bigger apps this is exactly the job of dedicated libraries (TanStack Query on the web, or a repository + Flow layer on Android): they own the cache, the staleness, the refetching, so your screen doesn’t have to.
Notice this gently bends Rule 1. Locally, “don’t store what you can derive.” But server data can’t be derived — you genuinely must cache a copy of something you don’t control. The owner here isn’t a component; it’s the cache key.
Takeaway: Remote data is a cache you don’t own. Give it its own loading/error/stale machinery — don’t fake it with a boolean.
Rule 8: Model transitions, not just states
Rule 2 made illegal states impossible. But there’s a subtler bug: a perfectly legal state can be reached by an illegal move. You should never jump from “showing an error” straight to “loaded” without going through “loading” — yet nothing stops you.
The cure is to think in terms of a state machine: from each state, only certain events lead to certain next states. Everything else is rejected.
Bad — you can teleport to Loaded from anywhere, even if you never actually loaded:
fun onResult(data: List<Item>) {
state = ScreenState.Loaded(data) // valid even from Error, or from nothing
}
Good — a reducer decides the next state based on both the current state and the event, and ignores moves that don’t make sense:
fun reduce(state: ScreenState, event: Event): ScreenState =
when (state) {
is ScreenState.Loading -> when (event) {
is Event.DataArrived -> ScreenState.Loaded(event.data)
is Event.Failed -> ScreenState.Error(event.message)
else -> state // illegal move → no change
}
else -> state
}
For complex flows (multi-step forms, media players, checkout), this is what tools like state-machine libraries formalize. But even a hand-written reducer like this gets you most of the safety.
Takeaway: Don’t just ask “which states are legal.” Ask “which moves between states are legal,” and block the rest.
Rule 9: Give entities one identity and store them once
When the same real-world thing appears in many places, you have a choice: copy it into each place, or store it once and point at it from everywhere. Copying guarantees drift — edit the user’s name in one post and the other posts still show the old name.
The professional answer is normalization: keep one canonical copy of each entity, indexed by its id, and reference it by id everywhere else. (This is the same idea as foreign keys in a database.)
Bad — the author is embedded inside every post, so the same user exists in five different places:
data class Post(
val id: Int,
val author: User // a full duplicate copy, repeated across posts
)
Good — one copy of each user, referenced by id:
data class Post(
val id: Int,
val authorId: Int // just a pointer to the single source
)
val usersById: Map<Int, User> // edit a user here once → every post reflects it
The “stable keys for list rows” from Rule 5 is really this same principle showing up in the UI: identity is what lets the framework (and you) track which thing is which over time.
Takeaway: One thing, one copy, referenced by id everywhere else.
Rule 10: Some truth lives outside your component tree
Not all state belongs inside your screen. The current search query, the selected tab, the page number, the open item — these often belong in the URL (on the web) or in saved state (on mobile). Why? Because they should survive a page reload or screen rotation, and ideally be shareable as a link and restorable after the OS kills your app.
If that kind of state lives only in memory, you lose it the moment anything restarts.
Bad — the query lives only in the composable’s memory; rotate the device or share the screen and it’s gone:
var query by remember { mutableStateOf("") }
Good — SavedStateHandle is the real owner; the screen just mirrors it, and it survives process death:
val query: StateFlow<String> =
savedStateHandle.getStateFlow("query", "")
fun setQuery(value: String) {
savedStateHandle["query"] = value
}
This is just Rule 3 (“one owner”) extended past your components: sometimes the rightful owner of a fact is the URL, local storage, or the server — and your screen’s copy is a reflection of it, loaded in when the screen appears (that loading-in step is called hydration).
Takeaway: Sharable, survivable state belongs in the URL or saved state — your screen mirrors it, it doesn’t own it.
Rule 11: Effects synchronize the outside world — and must clean up
So far the screen has been pure: data in, picture out. But real apps must also touch the outside world — open sockets, subscribe to listeners, start timers, log analytics. These are side effects, and they have a precise job: keep some external thing in sync with your current state.
The key insight that trips people up: an effect is not a “do this once” callback. It’s a living connection that must be torn down when its inputs change or the screen goes away — otherwise you leak resources (and stack up duplicate connections).
Bad — a new socket opens on every redraw, and none of them ever close:
@Composable
fun Live(url: String) {
val socket = openSocket(url) // leaks, and piles up with every recomposition
// ...
}
Good — DisposableEffect(url) ties the socket’s life to the url: when url changes (or the screen leaves), the old socket is closed before a new one opens:
@Composable
fun Live(url: String) {
DisposableEffect(url) {
val socket = openSocket(url)
onDispose { socket.close() } // cleanup runs on url change or when leaving
}
}
The mental model: “Given this state, what should the outside world look like — and how do I undo it when the state changes?” Always pair the setup with its teardown.
Takeaway: Effects keep the outside world in sync with state. Every setup needs a matching cleanup, keyed to its inputs.
Rule 12: Stay consistent when things happen at once
The moment you have async work, you have races. Type “a”, “ab”, “abc” quickly into a search box, fire three network calls, and there’s no guarantee they come back in order. If the response for “a” arrives last, it overwrites the results for “abc” — and the user sees the wrong list.
The rule: the latest request wins; cancel the stale ones.
Bad — every keystroke launches a call, and a slow old one can land last and clobber the newest result:
fun onQueryChange(query: String) {
scope.launch {
results = api.search(query) // many in flight, order not guaranteed
}
}
Good — mapLatest cancels the previous search the instant a new query arrives, so only the latest result can ever land:
val results: StateFlow<List<Item>> =
queryFlow
.debounce(300) // wait for typing to pause
.mapLatest { query -> api.search(query) } // new query cancels the old call
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
Two related traps in this family: stale closures (an async block captures an old value of a variable and uses it after it’s outdated) and tearing (a concurrent redraw reads two related values mid-update and sees an inconsistent mix — read everything from one consistent snapshot to avoid it).
Takeaway: With concurrency, make the latest action win and cancel the rest — never let a slow old result overwrite a fresh one.
Part 3 — Free Wins
Get the core right and some genuinely hard features fall out almost for free.
Rule 13: Immutable state gives you undo/redo for free
If you change state by replacing it with a new copy (rather than mutating it in place), then the old copies are still intact. Keep them in a stack and you have undo, redo, even full time-travel debugging — at almost no extra cost.
Bad — mutating in place destroys the previous state; there’s nothing to go back to:
fun addItem(item: Item) {
items.add(item) // the "before" is gone forever
}
Good — push the old snapshot, then move forward with a copy. Undo is just popping the stack:
private val history = ArrayDeque<HomeState>()
fun addItem(item: Item) {
history.addLast(state)
state = state.copy(items = state.items + item)
}
fun undo() {
state = history.removeLastOrNull() ?: state
}
Takeaway: Replace state, don’t mutate it, and history is free.
Rule 14: Form validation is derived, not stored
A classic bug factory: keep an emailError variable next to email, and update it by hand whenever email changes. Miss one update path and the error message lies. But error messages are derivable from the input — so by Rule 1, don’t store them.
Bad — a separate error variable you have to remember to keep in sync:
var email by mutableStateOf("")
var emailError by mutableStateOf<String?>(null)
fun onEmailChange(value: String) {
email = value
// forget to recompute emailError here → it shows the wrong message
}
Good — the error is computed from the email, so it’s always correct:
var email by mutableStateOf("")
val emailError by remember {
derivedStateOf {
if (email.contains("@")) null else "Enter a valid email"
}
}
Takeaway: Store what the user typed. Compute whether it’s valid.
The whole guide on one page
| # | Rule | In one sentence |
|---|---|---|
| 0 | UI = f(state) | Change data; the screen redraws itself. |
| 1 | Minimal facts | Don’t store what you can compute. |
| 2 | Illegal states impossible | Use unions so bad combinations can’t exist. |
| 3 | One owner | Each fact lives in exactly one place, as local as possible. |
| 4 | Data down, events up | Values flow down, changes bubble up, one direction. |
| 5 | Recompute dependents only | Memoize; re-run work only when its inputs change. |
| 6 | Nest the shapes | Compose facts into one screen-state object. |
| 7 | Server state ≠ client state | Remote data is a cache you don’t own; give it real machinery. |
| 8 | Transitions, not states | Block illegal moves, not just illegal states. |
| 9 | Identity + normalization | One copy per entity, referenced by id. |
| 10 | Truth outside the tree | Sharable/survivable state lives in URL or saved state. |
| 11 | Effects = sync + cleanup | Keep the outside world in sync; always tear down. |
| 12 | Latest wins | Cancel stale async work; never let it overwrite fresh results. |
| 13 | History free | Replace state instead of mutating → undo/redo for free. |
| 14 | Validation derived | Store the input, compute the error. |
And if you forget all fourteen, remember this:
Store the minimum set of true facts. Derive everything else. Shape the facts so wrong combinations can’t be written. Push servers, effects, and shared truth to the edges. Then the screen is just a picture of your data — and a wrong picture becomes almost impossible to draw.
Where does the real pain in most apps actually live? Rule 7 — server state. Get that one right and you’ve removed the biggest source of bugs in modern frontends.