UI = f(state) Is Folklore. I'm Betting on It Anyway.
Nobody official ever wrote the formula everyone quotes. But the shape keeps getting rediscovered independently, and the constraints it imposes are the whole product. My stance, the honest bear case, and what I still haven't proved.
Go looking for the source of “UI = f(state)” and you won’t find one.
I went looking. It isn’t in React’s docs. It isn’t in Compose’s. Facebook never wrote it, Google never wrote it, and the closest anyone official came was Sebastian Markbåge in react-basic in 2016, saying UIs are “a projection of data into a different form of data.” The formula everyone chants at each other is folklore. It propagated because it’s useful, not because it’s sourced.
The one place a major vendor said the real thing out loud is Apple, WWDC 2019, introducing SwiftUI:
“Views are a function of state, not a sequence of events.”
That second clause is the whole architecture, and it’s the half everyone drops when they quote it. The claim isn’t that your UI depends on data. Every UI ever written depends on data. The claim is that your UI depends on state and nothing else: not on the order you got there, not on which callback fired, not on what the view was showing thirty seconds ago.
I’ve been building on that bet since 2019, when I wrote ReduxKotlin. I just shipped nine posts on the 1.0 line. This is the post where I say why I still think it’s right, what it actually costs, and the parts I haven’t earned yet.
The shape keeps getting rediscovered, which is the tell
Credit where it’s owed, in order, because this lineage gets mangled constantly.
1997. Conal Elliott and Paul Hudak publish Functional Reactive Animation. FRP is born.
2012. Evan Czaplicki’s Elm thesis is literally titled Concurrent FRP for Functional GUIs.
2014. Facebook ships Flux. Read their actual argument and it isn’t “MVC is bad.” It’s narrower and better: “two-way data bindings led to cascading updates, where changing one object led to another object changing, which could also trigger more updates.” The enemy was never MVC. The enemy was the cascade.
Also 2014: André Medeiros (now André Staltz) coins MVI in Reactive MVC and the Virtual DOM. Not Hannes Dorfmann, who brought it to Android three years later and gets miscredited for it constantly.
2015. Dan Abramov writes Redux. The v1.0.0 README credits Elm by name, and the story of how he got there is better than the library:
“Then Andrew Clark suggested we just combine reducer functions into a single reducer function. This would kill the need for dispatcher. […] Later I realized that this is exactly Elm architecture, and I just didn’t understand it at first.”
Source: Abramov, SurviveJS interview, September 2015
2016. Elm publishes A Farewell to FRP and drops the label entirely. The thing that came out of FRP disowned FRP.
And the Elm guide, on where The Elm Architecture came from:
“Rather than someone inventing it, early Elm programmers kept discovering the same basic patterns in their code.”
That’s the sentence I keep coming back to. Nobody designed this. Elm fell into it. Abramov fell into it and only recognized it afterward. Apple arrived independently in 2019, Google arrived independently with Compose, and Spotify’s Mobius landed on a pure Update returning a new model plus a set of effects, which is Elm’s (model, Cmd msg) down to the bones, without their docs ever mentioning Elm.
When smart people working separately keep tripping over the same shape, the shape is load-bearing. That’s not proof it’s correct. It is strong evidence it’s there.
The constraints are the product
Here’s the thing that took me years to see, and it’s the core of my stance.
Redux exists because Abramov wanted to demo a debugger. His own v1.0.0 README:
“I wrote Redux while working on my React Europe talk called ‘Hot Reloading with Time Travel’. My goal was to create a state management library with minimal API but completely predictable behavior, so it is possible to implement logging, hot reloading, time travel, universal apps, record and replay, without any buy-in from the developer.”
Read that backwards and you understand the whole design. Immutability isn’t an aesthetic preference. Serializable actions aren’t ceremony. They are exactly and only what a time-travel debugger requires. As Mark Erikson put it, if a reducer mutates state, “jumping between actions in the debugger will result in inconsistent values.”
So the causality runs opposite to how it’s usually taught. You don’t adopt immutability because it’s virtuous and then discover you get nice tools. You accept a set of constraints, and the tools become possible. Every property people like about this architecture is downstream of a restriction they complain about.
That trade is the entire pitch, and it’s why I get impatient with “Redux is too much boilerplate.” Yes. That’s the price. The question is never whether the constraint costs something. It’s whether the thing you buy is worth more, and that depends entirely on what you’re building and what language you’re building it in.
What the constraint actually buys
Predictability
A reducer in ReduxKotlin is a typealias, not a framework:
public typealias Reducer<State> = (state: State, action: Any) -> StateThat’s it. Pair it with a sealed action hierarchy and an exhaustive when, and the compiler starts telling you about states you forgot to handle. Not at runtime, in review. In the compiler.
The discipline that makes this real is smaller than people expect, and it’s mostly about what you don’t let into the function. Here’s a reducer from the TaskFlow sample:
fun boardReducer(model: BoardModel, action: Action, selfId: AccountId): BoardModel = when (action) { is LoadBoardSucceeded -> BoardModel(action.board) is BoardRestored -> BoardModel(action.board) is BoardClosed -> BoardModel(null) else -> { val board = model.board val next = board?.let { mutateBoard(it, action, selfId) } if (next === board) model else BoardModel(next) } }No injected services. No store reference. No coroutine scope. And critically: no clock. Timestamps arrive pre-minted on the action. I grepped every reducer in the sample for Clock.System, Random, and currentTimeMillis and there are zero hits. That isn’t fastidiousness for its own sake. A reducer that reads the clock is a reducer that can’t be tested, can’t be replayed, and can’t be snapshotted, and you will discover this the week before you need to do all three.
Testability, with a real number
I measured this rather than asserting it, because the number is the argument.
TaskFlow’s BoardReducersTest: 50 tests in 18 milliseconds. About 0.36ms each. No emulator, no Robolectric, no mocks, no test dispatcher, no runTest, no advanceUntilIdle. You call a function with a value and assert on the value it returns, which is the fastest kind of test that exists because it’s barely a test at all. It’s arithmetic.
Now the honest half, and it’s the one nobody puts on the slide: that 18ms of tests sits behind 24 to 38 seconds of Gradle. The tests are free. The build is not. If I tell you f(state) gives you a fast iteration loop, I’m telling you the truth about a component that is currently three orders of magnitude smaller than its own build system.
That’s not a reason to give up the property. It’s a reason to be precise about where the time actually goes, and to be suspicious of anyone selling architecture on iteration speed without showing you their build.
Snapshots: f(state) all the way to the pixels
If UI is a function of state, then a screenshot is a function of state too, and you can call that function directly.
That’s what rk snapshot does. Most screenshot tools are f(@Composable args) -> PNG. This one is f(state) -> PNG: you seed a store, dispatch into it, and it renders your real Compose screen against the resulting state. I wrote about the mechanics already.
On my laptop: about 380ms for the first render (Skia and the JVM waking up), then 17 to 18ms per render after that.
The prior art here is worth naming, because I didn’t invent this. Point-Free got to it first and from the same premise: their SwiftUI Snapshot Testing episode (2019) snapshots alerts and modals, and they’re snapshottable precisely because in that architecture an alert is state rather than an imperative presentation call. Compose’s own @PreviewParameter is the same idea, officially blessed: seed data, render UI, never launch the app. Paparazzi and Roborazzi render without a device. None of this is novel. What f(state) adds is that the seed is your actual application state, so the thing you rendered is a state your app can really be in, rather than a set of arguments you talked yourself into.
And the bit I keep finding underrated: once the UI is a function of state, you can also assert on the semantics tree instead of the pixels. Text is content, not paint. A one-character text bug moved 0.0072% of pixels in my sample app and sailed straight under the pixel tolerance while the semantics gate caught it cold.
Cross-platform, which is where this gets unfair
A reducer is a pure function over data classes. It has no threading model, no lifecycle, no view hierarchy, no platform. It is about the most portable code you can write, which is why the Redux layer of a KMP app needs almost no platform shim at all.
In the dial framing, this is detent 3, and it’s the detent with the best ratio of value shared to platform pain incurred. You’re sharing the answer to “what happens when the network drops mid-save,” and you’re answering it once.
The bear case, steelmanned
If I only give you the good half, you should stop reading me.
The all-or-nothing problem. The best critique of this architecture lives in Redux’s own issue tracker, #1385. Jason Quense, 2016:
“the biggest downside for us is that single state atoms is sort of an all or nothing thing. The benefits of easy hydration, snapshots, time travel, etc only work if there is no other place important state lives.”
Sit with the shape of that. The payoffs are superlinear and only arrive near 100% adoption. The costs (indirection, serializability, ceremony) are linear and charged at every single percentage point. And 100% is unreachable: text input, scroll position, caret, animations, canvas handles, in-flight requests all resist the store. So it is genuinely possible to pay the full tax and collect a fraction of the benefit. That’s not a strawman. That is the default outcome for a team that adopts this without understanding what they bought.
Abramov agrees, incidentally, in the same thread: “we don’t intend Redux to be used for all state.” And his own sharpest self-criticism:
“My biggest pet peeve is it is harder to compose, reuse, nest, and generally move around container components because there are two independent hierarchies at the same time (views and reducers)… I would like to see something like React local state model but backed by reducers…”
He wrote that in 2016. He’s describing useReducer, which shipped about three years later.
It’s half a pattern. David Khourshid’s critique is the one I find hardest to answer. Reducers conflate finite state with extended state, so nothing tells you which states are legal. Transitions key on the event alone rather than on (state, event), so impossible transitions stay expressible. And effects are second-class: the thunk-versus-saga-versus-observable fragmentation isn’t ecosystem noise, it’s a hole in the middle of the architecture that everyone patches differently. He’s right, and statecharts have had the answer since Harel described them in 1987.
The JS world moved, and I should say so. Redux’s share of React downloads has gone from roughly 54% to 25% over five years. Zustand now out-downloads it. State of React 2025 puts Redux at 75% usage but 34% retention, against Zustand’s 50% usage and 94% retention. Retention is the number that matters: it’s the fraction who’d use it again. Redux’s own documentation now says the core package is “obsolete”.
The honest reading isn’t “Redux lost.” It’s that React Query removed about 80% of what people were actually using Redux for. Most “state” in a typical app was never client state at all. It was a cache of someone else’s database, and it wanted invalidation and staleness, not reducers. Tanner Linsley’s framing, that server state is “persisted remotely in a location you do not control”, is just correct, and Redux was the wrong tool for it the entire time.
Why Kotlin changes the arithmetic
Here’s the argument that made me keep going, and it came from a 2016 Hacker News comment complaining about Redux:
“it tries to enforce pure functions, message passing and immutability, with no help from the language… If you want immutability, pure functions and message passing, then why not use a language that is built around them?”
That’s a devastating criticism of Redux-in-JavaScript. It is not a criticism of the architecture. It’s a criticism of the substrate.
Go down the list of things people hate about Redux and ask which are the paradigm’s and which are JavaScript’s. Action-type string constants? That’s a language without sealed types. switch statements with a default that silently swallows unknown actions? That’s a language without exhaustiveness checking. Immer, and the 100x-to-400x overhead arguments around it? That’s a language without copy(). The immutability discipline that has to be enforced by convention and code review? That’s a language without val.
Kotlin has sealed hierarchies, exhaustive when that fails the compile rather than the runtime, data classes with structural equality and copy(), and immutable collections. Most of Redux’s famous boilerplate is JavaScript paying, in ceremony, for things Kotlin gives you in the type system.
That’s the bet. Not that Redux was right and the JS ecosystem was wrong. That the architecture was always sound and was being run on a substrate that charged full price for every one of its constraints, and Kotlin charges a fraction.
The neighbors, and what they got right
I’m not alone out here and I’d rather point at the neighborhood than pretend I’m the only house on it.
The Composable Architecture is the closest philosophical sibling, and its README credits “Elm and Redux” by name. It’s also further along than I am on two things I want: effects are values returned by the reducer (Elm’s Cmd, not middleware), and composition is a first-class abstraction rather than something you improvise. It has earned its critiques too, and they’re instructive rather than fatal: Krzysztof Zabłocki measured a single-store Arc feature burning 9.61 seconds of CPU across 210,000 events, dropping to 0.91s once split into multiple stores. Point-Free responded, honestly, by shipping non-exhaustive test stores. That’s what a healthy project under criticism looks like.
Circuit (Slack) and Molecule (Cash App) are the most interesting thing happening in this space, because in both of them the reducer disappears. Molecule runs a @Composable as a coroutine and collects its return value into a StateFlow. The composable emits a model, not UI. The insight is that the Compose runtime was never really a UI toolkit. It’s a general-purpose state-tracking engine that happens to also draw pixels. If that’s true, a lot of the machinery I maintain is a workaround for a runtime that already exists.
I don’t think it’s fully true yet. I do think it’s the most credible threat to my position, and pretending otherwise would be cowardice.
And the one that should give any Redux maintainer pause: FlowRedux is by Hannes Dorfmann, the man who brought MVI to Android. He built a state-first state machine DSL (inState<Loading> { onEnter { ... } }) rather than an action-first reducer. Same author, opposite axis, and his version answers Khourshid’s finite-state critique directly.
Also worth your time: MVIKotlin and Decompose (Arkadii Ivanov), Orbit, Workflow (Block), Ballast, and Mavericks (Airbnb).
Where I’m still wrong
The point of a stance is that it’s falsifiable. So here’s what ReduxKotlin does not currently do, stated plainly, before someone else states it for me.
DevTools time travel is read-only. You can scrub the timeline and inspect any recorded state. You cannot push the app back into one. The monitor’s own label says time-travel · read-only, and the source comment is blunt: “No time-travel recomputation: that is phase 2.” The inbound JUMP_TO_STATE message from the Redux DevTools protocol is currently ignored. I have described this as “time travel” in earlier writing with less precision than it deserved, and I’m correcting that here. This is the single largest gap between what the architecture promises and what my library delivers, and it’s galling, because time travel is the thing Redux was invented to demonstrate.
There’s no action replay. DevTools records a perfectly good .jsonl action log and nothing feeds it back through a reducer. actions.fold(initialState, reducer) is a one-liner. That it isn’t shipped is not a design position, it’s a to-do.
Immutability is a convention, not an invariant. The entire runtime check on the way into the store is:
internal fun isPlainObject(obj: Any): Boolean = obj !is Function<*>“Actions must be plain objects” currently means “your action is not a lambda.” Nothing stops a reducer mutating its argument in place and returning the same reference, and if you do, change detection compares by identity first and your UI silently won’t update. Kotlin gives me the tools to do far better here than JavaScript can. I haven’t used them yet.
I have no committed benchmarks. There’s a JMH harness with 18 benchmarks in the repo and zero recorded results, and some latency figures in the docs are budgets from a design document rather than measurements. That’s the sort of thing I’d call out in someone else’s project, so I’ll call it out in mine. Every number in this post I measured on my own laptop this week, which is better than nothing and worse than a benchmark suite.
Snapshot goldens aren’t cross-OS stable yet. TaskFlow ships zero committed goldens because macOS and Linux disagree about glyph advances by enough to shift a layout, and the font-determinism harness that would fix it is specced and unbuilt. A visual regression tool that can’t currently do visual regression on its own flagship sample is a tool with homework.
DevTools on non-JVM targets are thin. State serializes through toString() on JS, wasm, and native by default, so you get one opaque string instead of a tree. The bindings are genuinely nine-platform. The tooling is JVM-first and I should stop implying otherwise.
The stance
UI as a function of state is right, and the reason it’s right is not that it’s elegant. It’s that it’s the only formulation where the properties you want (replayability, testability, portability, snapshotability, a debugger that can show you a past you didn’t plan to record) stop being features somebody has to build and start being consequences of the shape. You don’t add testability to a pure function. It’s already there. You just have to not ruin it.
The constraints are the product. The boilerplate is the invoice. And the reason I’m still doing this in Kotlin, seven years in, is that Kotlin is the first mainstream language I’ve used where the invoice is small, where sealed types, exhaustive when, copy(), and val mean you pay in the type system instead of in ceremony.
The list above is what’s still owed: real time travel, real replay, real enforcement, real numbers. That’s the roadmap, and I’d rather publish it as a debt than as a feature list.
If you’re deciding how much of your app to share across platforms, the dial post is the practical companion to this one. If you want the mechanics rather than the manifesto, the 1.0 series has all nine parts.
Happy state-threading, and may your functions stay pure enough to replay.
Sources of truth: reduxkotlin.org · github.com/reduxkotlin/redux-kotlin · Redux’s own prior-art page · The Elm Architecture