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.
Local Storage Options Compared
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 |
Preferences Storage with DataStore
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")
}
}
Code language: HTML, XML (xml)
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
)
}
}
Code language: PHP (php)
SQLDelight for Structured Data
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 = ?;
Code language: PHP (php)
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)
}
}
Handling Remote Data with Ktor
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
)
Code language: HTML, XML (xml)
Caching Strategy: Local First, Remote Fallback
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"))
}
}
}
}
Platform-Specific Storage Considerations
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 ...
}
Code language: JavaScript (javascript)
Error Handling and Loading States
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
)
}
}
Code language: PHP (php)
Testing Your Storage Layer
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)
}
}
}
Code language: PHP (php)
Final Thoughts
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:
- Choose the right storage for each type of data
- Cache intelligently to minimize network usage
- Handle errors gracefully – show cached data when possible
- Respect platform differences in storage capabilities
- Test thoroughly – storage bugs are the worst kind
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.