Efficient data handling is the backbone of modern apps. Whether you’re building for Android, iOS, desktop, or web with Compose Multiplatform, mastering local and remote storage ensures seamless user experiences. This guide dives deep into SQLDelight, Ktor, offline-first architecture, and secure cross-platform practices for 2025.
Ever built an app that forgets everything when you close it? Feels about as useful as a grocery list written in disappearing ink. Proper data handling is what turns your flashy UI into something people actually want to use. With Compose Multiplatform, we can manage data storage consistently across Android, iOS, desktop, and web – no more platform-specific headaches.
I’ve wrestled with enough data persistence challenges to know that getting storage right makes all the difference between an app that feels polished and one that frustrates users. Let’s break down how to handle both local and remote data storage in Compose Multiplatform apps that work beautifully everywhere.
Before we dive into code, let’s look at our local storage options:
| Storage Type | Best For | Android | iOS | Desktop | Web |
|---|---|---|---|---|---|
| Preferences | Simple key-value pairs | SharedPreferences | NSUserDefaults | Preferences API | localStorage |
| SQLDelight | Structured relational data | Room | SQLite | SQLite | IndexedDB |
| DataStore | Type-safe preferences | Available | Available | Available | Available |
| File System | Large files/blobs | Files API | Files API | Files API | Browser FS |
Let’s start with the simplest option – storing key-value pairs. Here’s how to use DataStore in Compose Multiplatform:
@Serializable
data class UserSettings(
val darkMode: Boolean,
val fontSize: Int
)
class SettingsRepository(private val dataStore: DataStore<Preferences>) {
val userSettings: Flow<UserSettings> = dataStore.data
.map { prefs ->
UserSettings(
darkMode = prefs[PreferencesKeys.darkModeKey] ?: false,
fontSize = prefs[PreferencesKeys.fontSizeKey] ?: 16
)
}
suspend fun updateDarkMode(enabled: Boolean) {
dataStore.edit { settings ->
settings[PreferencesKeys.darkModeKey] = enabled
}
}
companion object {
val darkModeKey = booleanPreferencesKey("dark_mode")
val fontSizeKey = intPreferencesKey("font_size")
}
} To use this in your UI:
@Composable
fun SettingsScreen(settingsRepository: SettingsRepository) {
val settings by settingsRepository.userSettings.collectAsState()
Column {
Switch(
checked = settings.darkMode,
onCheckedChange = {
lifecycleScope.launch {
settingsRepository.updateDarkMode(it)
}
}
)
Slider(
value = settings.fontSize.toFloat(),
onValueChange = { /* Similar update logic */ },
valueRange = 12f..24f
)
}
} For more complex data, SQLDelight is my go-to choice. Here’s how to set it up:
First, define your schema:
CREATE TABLE todo_item (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
selectAll:
SELECT * FROM todo_item
ORDER BY created_at DESC;
insertItem:
INSERT INTO todo_item(title, completed, created_at)
VALUES(?, ?, ?);
deleteItem:
DELETE FROM todo_item
WHERE id = ?; Then create the repository:
class TodoRepository(private val database: TodoDatabase) {
fun getAllTodos(): Flow<List<TodoItem>> {
return database.todoItemQueries.selectAll()
.asFlow()
.mapToList()
}
suspend fun addTodo(title: String) {
database.todoItemQueries.insertItem(
title,
false,
System.currentTimeMillis()
)
}
suspend fun deleteTodo(id: Long) {
database.todoItemQueries.deleteItem(id)
}
} Now let’s connect to remote APIs. Here’s a complete Ktor client setup:
internal val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 15000
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.HEADERS
}
}
class NewsApi {
suspend fun fetchLatestNews(): List<NewsItem> =
httpClient.get("https://api.example.com/news").body()
}
@Serializable
data class NewsItem(
val id: String,
val title: String,
val summary: String,
val publishedAt: String
) The real magic happens when we combine local and remote storage. Here’s a robust caching strategy:
class NewsRepository(
private val api: NewsApi,
private val database: NewsDatabase
) {
fun getNews(): Flow<Resource<List<NewsItem>>> = channelFlow {
// Emit loading state
send(Resource.Loading())
try {
// First emit cached data
val cachedNews = database.newsQueries.selectAll().executeAsList()
if (cachedNews.isNotEmpty()) {
send(Resource.Success(cachedNews))
}
// Then fetch fresh data
val freshNews = api.fetchLatestNews()
database.transaction {
database.newsQueries.clearAll()
freshNews.forEach { news ->
database.newsQueries.insertItem(
news.id,
news.title,
news.summary,
news.publishedAt
)
}
}
// Emit fresh data
send(Resource.Success(freshNews))
} catch (e: Exception) {
// If error but we have cached data, show that
val cached = database.newsQueries.selectAll().executeAsList()
if (cached.isNotEmpty()) {
send(Resource.Success(cached, fromCache = true))
} else {
send(Resource.Error(e.message ?: "Unknown error"))
}
}
}
} Each platform has its quirks when it comes to storage:
| Consideration | Android | iOS | Desktop | Web |
|---|---|---|---|---|
| File Access | Needs permissions | Sandboxed | Full access | Limited |
| Background Sync | WorkManager | BackgroundTasks | No limit | ServiceWorker |
| Data Encryption | EncryptedSharedPrefs | Keychain | OS keyring | Limited |
| Storage Limits | High | Moderate | Very high | Low |
Here’s how we might handle file storage differently per platform:
expect class FileStorage() {
fun saveFile(name: String, data: ByteArray): Boolean
fun readFile(name: String): ByteArray?
}
// Android implementation
actual class FileStorage actual constructor() {
actual fun saveFile(name: String, data: ByteArray): Boolean {
return try {
File(context.filesDir, name).writeBytes(data)
true
} catch (e: Exception) {
false
}
}
// ... other methods ...
}
// iOS implementation
actual class FileStorage actual constructor() {
actual fun saveFile(name: String, data: ByteArray): Boolean {
return NSFileManager.defaultManager.createFileAtPath(
path = getDocumentsDirectory().resolve(name),
contents = data.toNSData(),
attributes = null
)
}
// ... other methods ...
} Users hate staring at a spinner forever. Here’s how to handle loading states gracefully:
@Composable
fun NewsScreen(newsRepository: NewsRepository) {
var news by remember { mutableStateOf<Resource<List<NewsItem>>>(Resource.Loading()) }
LaunchedEffect(Unit) {
newsRepository.getNews().collect { result ->
news = result
}
}
when (val result = news) {
is Resource.Loading -> FullScreenLoading()
is Resource.Error -> ErrorScreen(result.message) {
// Retry logic
}
is Resource.Success -> NewsList(
items = result.data,
isStale = result.fromCache
)
}
} Don’t skip testing your storage logic! Here’s how to test our repository:
class NewsRepositoryTest {
private val testDb = createTestDatabase()
private val mockApi = MockNewsApi()
private val repo = NewsRepository(mockApi, testDb)
@Test
fun `should return cached data when offline`() = runTest {
// Given cached data exists
testDb.newsQueries.insertItem("1", "Cached", "Old news", "2023-01-01")
// When network fails
mockApi.shouldFail = true
// Then we get cached data
repo.getNews().first { it is Resource.Success }.let { result ->
assertTrue((result as Resource.Success).fromCache)
assertEquals(1, result.data.size)
}
}
} Getting data storage right in Compose Multiplatform isn’t just about making things work – it’s about creating experiences that feel fast, reliable, and consistent across all platforms. The key principles to remember:
When done well, your users won’t even notice your storage layer – they’ll just enjoy an app that works quickly and reliably, online or off. And that’s the real magic of great data handling.
Introduction: Transform Your Cross-Platform Development with Material Design 3 Are you ready to revolutionize your… Read More
Jetpack Compose 1.8 rolls out handy features like Autofill integration, slick Text enhancements including auto-sizing… Read More
Reified Keyword in Kotlin: Simplify Your Generic Functions Kotlin's reified keyword lets your generic functions know the… Read More
Android Studio Cloud: Ditch the Setup, Code Anywhere (Seriously!) Alright, fellow Android devs, gather 'round… Read More