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 TypeBest ForAndroidiOSDesktopWeb
PreferencesSimple key-value pairsSharedPreferencesNSUserDefaultsPreferences APIlocalStorage
SQLDelightStructured relational dataRoomSQLiteSQLiteIndexedDB
DataStoreType-safe preferencesAvailableAvailableAvailableAvailable
File SystemLarge files/blobsFiles APIFiles APIFiles APIBrowser 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:

ConsiderationAndroidiOSDesktopWeb
File AccessNeeds permissionsSandboxedFull accessLimited
Background SyncWorkManagerBackgroundTasksNo limitServiceWorker
Data EncryptionEncryptedSharedPrefsKeychainOS keyringLimited
Storage LimitsHighModerateVery highLow

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:

  1. Choose the right storage for each type of data
  2. Cache intelligently to minimize network usage
  3. Handle errors gracefully – show cached data when possible
  4. Respect platform differences in storage capabilities
  5. 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.

0 0 votes
Article Rating

Leave a Reply

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments