Patrick Jackson · blog

Redux DevTools, Outside the Browser

1,412 words7 min read#kotlin#kotlin-multiplatform#redux#reduxkotlin#devtools#debugging

Redux’s original developer tools were a browser extension, and they were the single best argument for the whole pattern. Every action, in order, with the state before and after and a diff between them, and a slider to travel back through it. People adopted Redux because they saw that timeline.

Kotlin apps don’t run in a browser. The idea was too good to leave there.

The integration is one enhancer

implementation("org.reduxkotlin:redux-kotlin-devtools-core:1.0.0-alpha04")
val store = createConcurrentStore(
reducer,
initialState,
enhancer = devTools(DevToolsConfig(name = "MyApp")),
)

That’s the whole thing, and it goes in the same enhancer parameter you use for middleware. Every action, its resulting state, and a computed diff now flow into a session.

One design rule I held to throughout: DevTools instrumentation can never break the host store. Every path inside the enhancer is wrapped: a serializer that throws, a listener that explodes, a monitor that disappears mid-session. All of it is caught and routed to a logger, so your app never crashes because its debug tooling had a bad day.

Surface 1: the in-app drawer

Wrap your app, get a floating bubble and an edge swipe:

implementation("org.reduxkotlin:redux-kotlin-devtools-inapp:1.0.0-alpha04")
ReduxDevToolsHost(InAppConfig()) {
App()
}

The DevTools floating bubble trigger, sitting over a running app.

The DevTools drawer open inside a running app, showing the action list.

Five tabs: Actions, State, Diff, Pipeline, Outputs. It renders inside your own Compose tree, which means no SYSTEM_ALERT_WINDOW permission and no system overlay, and which also means it’s only there while your app is foregrounded. Fair trade.

New in alpha03 is ReduxDevToolsPanel: the inspector body with none of the drawer chrome (no bubble, no scrim) so you can drop it into a tab of a debug drawer you already have.

@Composable
public fun ReduxDevToolsPanel(
instanceId: String? = null,
startTab: DevToolsTab = DevToolsTab.ACTIONS,
theme: DevToolsThemeMode = DevToolsThemeMode.DARK,
)

I built this because I wanted it in my own app, which has a house debug drawer full of unrelated tools. Redux DevTools is now just one plugin in it.

Surface 2: the desktop monitor

The drawer is on a phone screen, which is a rough place to read a state tree. The desktop monitor is where I actually debug.

Terminal window
rk devtools serve --ui

Your app streams to it over a WebSocket bridge:

implementation("org.reduxkotlin:redux-kotlin-devtools-bridge:1.0.0-alpha04")
DevToolsHub.session("MyApp")?.let { session ->
BridgeOutput(BridgeConfig(clientId = "myapp", clientLabel = "My App")).start(session)
}

The Redux DevTools Monitor with TaskFlow connected: a store rail listing both TaskFlow stores, an action log of BotMovedCard and RecordActivity actions with change counts, the multi-model state tree, and a pipeline panel timing each middleware.

Four panels at once (store rail, action log, state/diff, pipeline) instead of the drawer’s five tabs, plus a time-travel timeline, global regex search, pause, and save/load of recorded sessions as .jsonl. It binds loopback by default; going off-loopback requires an explicit host and a shared token, checked against the connecting peer.

A few things in that screenshot are worth pointing at, because they’re the reason I use this surface and not the drawer:

  • The store rail lists both of TaskFlow’s stores, because it runs a root store plus one per account. You can watch one, or merge them into a single timeline ordered by time.
  • The action log puts a change count next to each action (, ), so you can see which actions actually did something before you click any of them.
  • The pipeline panel times every middlewareactivityLogger 135µs, undo 133µs, effects 130µs, then rootReducer. When a dispatch feels slow, that column tells you which middleware ate it, without you instrumenting anything.
  • 50 actions retained is DevToolsConfig.maxAge doing its job. It’s a ring buffer, not an unbounded log, so leaving the monitor attached to a long session doesn’t eat memory forever.

This is the surface I recommend. It’s also the only one that works for a headless, native, or server-side app, where there’s no Compose tree to draw a drawer into.

One bug to know about: in the current alpha04 Homebrew build, rk devtools serve --ui fails to launch the GUI because the bundled jlink runtime is missing a library AWT needs. The bridge itself serves fine. Until it’s fixed, run the monitor from source with ./gradlew :redux-kotlin-devtools-standalone:run. I found this while writing this post.

Surface 3: the command line

Same session, same events, printed as text. That turns out to matter enormously, and not only for humans.

Terminal window
rk devtools serve # captures stream into .rk-devtools/
# ...reproduce the bug in the app...
rk devtools stores # myapp::MyApp
rk devtools actions --last 30
rk devtools diff --type '*Failed*' --last 5
rk devtools state --at 42
rk devtools tail --follow

Six subcommands: serve, stores, actions, diff, state, tail. They share a set of filters: --store, --type (a glob), --last N, --since/--until (action IDs), --since-time/--until-time (timestamps), --pretty.

The design detail I’m proudest of is --format, which has three strictly nested tiers:

actions → { actionId, type, store, ts }
diff → + diff: [ { path, before, after } … ]
full → + state: { … the entire store snapshot … }

Output is one JSON object per line, and you pay for exactly the context you asked for. Scanning what happened costs four fields per action. Investigating one suspicious action costs its changed paths. Reconstructing the world at a single point costs the whole tree, and you only pay that when you actually need it.

I designed those tiers with coding agents in mind, and then found I use them constantly myself. rk devtools actions --last 30 is a much better first question than “let me dump the state and read it.”

The original extension still works

redux-kotlin-devtools-remote speaks the SocketCluster/RemoteDev protocol that @redux-devtools/cli and the browser extension’s remote monitor expect, right down to the double-encoded PERFORM_ACTION envelope. Point it at port 8000 and your Kotlin app shows up in the extension you already know.

The limits: the extension gets actions and state. It doesn’t get our diffs (it computes its own) and it doesn’t get the pipeline view at all, because that isn’t in its protocol. It’s a compatibility path and a bit of lineage, not the recommended surface. But there’s something satisfying about an Android app appearing in the Redux DevTools extension, and I wasn’t going to leave it on the table.

Which surface runs where

Worth knowing before you pick one, because this is the one place the nine-target story gets uneven:

  • The drawer needs a Compose tree, and it needs material3, which means it drops the desktop-native targets (linuxX64, mingwX64).
  • The bridge only needs a WebSocket client, so it compiles everywhere the library does. That’s why the desktop monitor is the only option for a headless, native, or server-side store.
  • The CLI is a JVM binary that reads capture files, so it doesn’t care what your app was built for.

The pipeline view

If you name your middleware, the drawer and the monitor can show you which middleware saw which action, in order:

val cfg = DevToolsConfig(name = "MyApp")
val enhancer = devToolsMiddleware(
cfg,
named("activityLogger", activityLoggerMiddleware()),
named("undo", undoMiddleware()),
named("effects", effectsMiddleware()),
)

One footgun, which I’ve walked into: colliding session IDs. Sessions are keyed by instanceId ?: name, so two stores that both take the default name will silently interleave into a single timeline. Name them.

Shipping it

redux-kotlin-devtools-inapp-noop mirrors the entire API with empty bodies. Substitute it in your release configuration and the whole thing compiles to nothing:

debugImplementation("org.reduxkotlin:redux-kotlin-devtools-inapp:1.0.0-alpha04")
releaseImplementation("org.reduxkotlin:redux-kotlin-devtools-inapp-noop:1.0.0-alpha04")

And a warning worth taking seriously: everything recorded is visible in every monitor. If your actions carry auth tokens or PII, they are in the timeline and in the .jsonl captures. Provide a custom ValueSerializer in DevToolsConfig and redact them.

The DevTools family is experimental and exempt from semver until it stabilizes.

Full details in the DevTools doc and the CLI tutorial.

Happy debugging, whether the eyes on that timeline are yours or your agent’s.


Sources of truth: reduxkotlin.org · github.com/reduxkotlin/redux-kotlin · the TaskFlow sample

Part 7 of ReduxKotlin 1.0.