Skip to main content

Compose Integration

redux-kotlin-compose bridges a redux-kotlin Store to Jetpack Compose / Compose Multiplatform. It turns a selected slice of store state into a Compose State<T>, so a Composable recomposes only when the slice it reads actually changes.

The bridge is built on top of Granular Subscriptions: each binding is a subscribeTo under the hood, so recomposition is scoped to the exact field a Composable observes rather than every dispatch.

Installation

implementation("org.reduxkotlin:redux-kotlin-compose:<version>")

The module depends on redux-kotlin-granular and the Compose runtime. It targets the platforms that Compose Multiplatform supports.

Binding a field: fieldState

The common case — bind one property of state to a State<T> using a Kotlin property reference:

import androidx.compose.runtime.getValue
import org.reduxkotlin.compose.fieldState

data class AppState(val user: User? = null, val count: Int = 0)

@Composable
fun Counter(store: Store<AppState>) {
val count by store.fieldState(AppState::count)
Text("Count: $count")
}

Counter recomposes when count changes, but not when an unrelated field (e.g. user) changes.

Deriving a value: selectorState

When you need to project or compute a value rather than read a property directly, use the lambda form:

import org.reduxkotlin.compose.selectorState

@Composable
fun OpenTodoBadge(store: Store<AppState>) {
val openCount by store.selectorState { it.todos.count { todo -> !todo.completed } }
Badge { Text("$openCount") }
}

The Composable recomposes only when openCount itself changes — marking a todo complete that doesn't alter the count won't trigger it.

:::note Selector stability The lambda passed to selectorState is remembered against the store and frozen at first composition. If the selector closes over other Composable state that should refresh the binding, prefer fieldState with a property reference, or re-key the subscription yourself inside a LaunchedEffect. The first frame is race-safe: the bridge re-samples the current state inside a DisposableEffect, so a dispatch landing between composition and commit is reflected immediately. :::

Skippability: StableStore

Compose's stability inferrer treats interfaces as unstable. Because Store<S> is an interface, a Composable that takes a Store<S> parameter directly becomes non-skippable — it recomposes unconditionally whenever its parent does.

Wrap the store in StableStore to restore skippability for downstream Composables:

import org.reduxkotlin.compose.StableStore
import org.reduxkotlin.compose.rememberStableStore
import org.reduxkotlin.compose.fieldState

@Composable
fun App(store: Store<AppState>) {
val stable = rememberStableStore(store)
Content(stable)
}

@Composable
fun Content(store: StableStore<AppState>) {
val user by store.value.fieldState(AppState::user)
// Content is now skippable: it only recomposes when `user` changes.
}

StableStore is a @Stable value class, so the wrapper costs nothing at runtime once inlined. rememberStableStore(store) is shorthand for remember(store) { StableStore(store) }.

Multi-model stores

If you use ModelState from redux-kotlin-multimodel, add redux-kotlin-compose-multimodel for property-reference bindings that resolve a field on a specific feature model — the call site never names ModelState:

implementation("org.reduxkotlin:redux-kotlin-compose-multimodel:<version>")
import org.reduxkotlin.compose.multimodel.fieldState

@Composable
fun ProfileHeader(store: Store<ModelState>) {
// M (LoggedInUserModel) is inferred from the property reference's receiver.
val displayName by store.fieldState(LoggedInUserModel::displayName)
Text("Hello, $displayName")
}

For callers that hold the model type as a KClass rather than a compile-time generic (raw JS/TS consumers, or generic helper code), use the non-inline fieldStateOf:

import org.reduxkotlin.compose.multimodel.fieldStateOf

val displayName by store.fieldStateOf(LoggedInUserModel::class) { it.displayName }

Saving state across rotation & process death

redux-kotlin-compose-saveable persists a slice of store state so it survives Android configuration changes / rotation and process death, restoring it when the app relaunches. It rides Compose's SaveableStateRegistry (the machinery behind rememberSaveable), so one mechanism covers both; on platforms without OS state restoration (desktop / JS / wasm) it is a safe no-op.

implementation("org.reduxkotlin:redux-kotlin-compose-saveable:<version>")

Why a singleton store isn't enough

A store held as a process/DI singleton already survives rotation — the process lives. But on process death the OS recreates the process, the singleton is rebuilt from its initial state, and that state is lost. And because the bindings above are one-directional (store → State), restoring a value only in a Composable would be overwritten by the store's initial state on the next subscription. The fix is to write the restored value back into the store the only way a store can change — by dispatching an action.

1. Describe what to save with StateSaver

A StateSaver is three things: a @Serializable snapshot of just the fields worth keeping (keep it small — it goes into the platform's saved instance state), a save projection from state, and a restore function that turns a decoded snapshot into an action your reducer applies.

import kotlinx.serialization.Serializable
import org.reduxkotlin.compose.saveable.StateSaver

@Serializable
data class UiSnapshot(val tab: Int, val query: String)

// An action your reducer handles:
data class RehydrateUi(val tab: Int, val query: String)

val uiSaver = StateSaver(
serializer = UiSnapshot.serializer(),
save = { s: AppState -> UiSnapshot(s.tab, s.query) },
restore = { RehydrateUi(it.tab, it.query) },
)

The reducer applies the restore action like any other:

fun appReducer(state: AppState, action: Any): AppState = when (action) {
is RehydrateUi -> state.copy(tab = action.tab, query = action.query)
// … other cases
else -> state
}

2. Anchor it with rememberSaveableState

Place the anchor once per persisted scope — typically near the root, or once per screen:

import org.reduxkotlin.compose.saveable.rememberSaveableState

@Composable
fun App(store: Store<AppState>) {
store.rememberSaveableState(uiSaver)
// child fieldState / selectorState bindings observe the rehydrated store
Screen(store)
}

On a real restore the snapshot is decoded and RehydrateUi is dispatched exactly once, before the bindings settle. On a normal cold start nothing is dispatched. The snapshot is serialized only when the platform actually saves (e.g. when the app is backgrounded) — there is no per-dispatch cost.

Lists, navigation & multiple anchors

The anchor derives a key from its call-site position. If you persist several independent scopes, place anchors inside a list, or move across a navigation graph where positions can collide, pass an explicit, stable key:

store.rememberSaveableState(detailSaver, key = "board-$boardId")

Versioning & failures

Restore is best-effort: if a saved snapshot can't be decoded (e.g. a new app version ships an incompatible UiSnapshot), it is dropped and the app starts cold rather than crashing. For additive changes pass a lenient codec via StateSaver(json = Json { ignoreUnknownKeys = true }); for breaking changes, add a version field to the snapshot and branch on it inside restore.

What to persist — and what not to

Persist the volatile UI state a user would miss: the current route or tab, an active filter or query, a selected item. Leave out anything that is transient interaction state:

  • Modes and overlays — if a detail screen was in an edit mode when the process died, restore it in view mode. Persisting the mode makes interrupted interactions feel sticky; the snapshot type simply doesn't carry the field.
  • Text drafts local to one Composable — keep them out of the store entirely. A plain rememberSaveable { mutableStateOf("") } rides the same SaveableStateRegistry with zero store involvement.
  • Anything durable — data that must survive a normal app restart (not just process death) belongs in real storage (a database, files), restored via preloadedState below.

Restore order & the first frame

The restore action is dispatched synchronously during composition of the anchor, before any child binding reads the store — so on a synchronous-dispatch store the very first frame already shows the rehydrated state. There is no intermediate frame rendered from the store's initial state. Place the anchor above the Composables that read the restored slice.

Restoration replays no events — key effects on state

A restore dispatches exactly one action (your restore action). None of the user events that originally produced the saved state are replayed: no clicks, no Navigate-style actions. Anything your app loads in response to an event therefore never loads on the restore path.

This is the same bug class as a web page that fetches in a click handler and breaks on browser refresh: restoration — like a deep link — enters a screen without the events that normally precede it. Redux adds more entry points with the same shape: DevTools time-travel, replay, and hydrating an account switch all set state without re-running events.

Two patterns survive all of them:

  • Derive the effect from state (preferred). Key the load on the state the restore produces, not on the action that usually produces it:

    val route by store.fieldState(NavState::route)
    DisposableEffect(route) {
    if (route is Route.Detail) store.dispatch(LoadDetailRequested(route.id))
    onDispose { /* cancel / close */ }
    }

    Because the restore is applied synchronously during composition, the effect's first key evaluation already sees the restored route and the load fires — exactly as it would after a real navigation.

  • React to the restore action in middleware (fallback). The restore action is a normal dispatch through the full middleware chain, so an effects middleware can match it and kick the loads explicitly.

Either way, write the restore action's downstream handling to tolerate stale references — a snapshot can outlive the data it points at (a deleted item, a removed board). Treat "referenced entity not found" as a navigate-away or empty state, never a crash.

Threading & platforms

The snapshot is read and the restore action dispatched on the main thread, so the persisted store must accept main-thread reads and dispatch — the Compose-facing store (the concurrent/threadsafe bundle store, or a main-confined store). The anchor rides whatever SaveableStateRegistry the platform's Compose runtime provides. On Android that registry is wired to savedInstanceState, so snapshots survive rotation and process death. On iOS, desktop, JS and wasm the Compose runtime does not currently wire the registry to an OS restore mechanism, so the anchor is a no-op for process death there — persist anything durable yourself and seed it via preloadedState instead.

:::tip Bundled redux-kotlin-compose-saveable ships inside redux-kotlin-bundle-compose — if you use the Compose bundle you already have it. :::

Rehydrating at construction: preloadedState

rememberSaveableState covers state the OS saves for you. For state you persist — a session loaded from disk, models read from a local database — seed the store with it at construction instead of dispatching it after the UI is up:

// Core store: pass the restored state as the initial state.
val store = createStore(::appReducer, restoredAppState)

// Routed ModelState store (routing / bundle modules): overlay restored
// models onto the declared defaults with `preloadedState`.
val store = createConcurrentModelStore(
preloadedState = ModelState.of(
NavModel(restoredStack),
FilterModel(restoredQuery),
),
) {
model(NavModel()) { /* handlers */ }
model(FilterModel()) { /* handlers */ }
model(BoardModel()) { /* handlers */ } // not preloaded — keeps its default
}

preloadedState overlays the declared defaults via ModelState.withAll(other) — its key set must be a subset of the declared models, and every slot you don't preload keeps its declared initial value. Because the store is born rehydrated, the first getState() / first render is already correct: no post-paint dispatch, no flash of initial state.

Choosing between the two: they compose, and a real app often uses both —

rememberSaveableStatepreloadedState
StorageOS saved-instance stateYour own (DB, files, server)
SurvivesRotation + process deathAnything, incl. normal restart
SizeSmall snapshots onlyWhatever you load
Restore pointFirst composition of the anchorStore construction

:::info Real-world example — TaskFlow The TaskFlow sample (ARCHITECTURE.md) splits persistence exactly this way: boards/cards/accounts are durable in SQLDelight (domain state, restored at store construction), while the per-account nav stack + board filter ride a single StateSaver<ModelState, UiSnapshot> anchored with an account-scoped key (key = "account-ui-$accountId"), restoring in view mode and keeping new-card drafts in plain rememberSaveable. :::

Lifecycle and threading

Each binding subscribes inside a DisposableEffect and unsubscribes in onDispose, so subscriptions follow the Composable's lifecycle automatically — no manual tear-down. The underlying granular subscription inherits the store's threading guarantees; if you dispatch from multiple threads, use a concurrent store (the bundle's createConcurrentModelStore, or createConcurrentStore from redux-kotlin-concurrent) or wrap the store with createThreadSafeStore.

fieldState / selectorState read store.state synchronously on every read — the subscription only schedules recomposition, it never caches a value. So whenever a binding is read (any recomposition, however triggered), it returns the store's current state, not a stale snapshot. The recomposition that a dispatch itself triggers rides the store's notification: inline contexts deliver it synchronously; a posting context delivers it on a later main-loop iteration. With a concurrent store, wrap the main-thread post in coalescingNotificationContext(isOnTargetThread, post) (from redux-kotlin-concurrent): a main-thread dispatch then notifies subscribers inline with no extra frame of latency, while off-main dispatches still marshal to main (at most one loop hop).

See also

  • Granular Subscriptions — the field-level subscription layer the Compose bridge is built on.
  • Ecosystem — the full first-party module list, including redux-kotlin-multimodel.