Quickstart
Let’s build your first Store.
In this example, we’ll build a simple Store to fetch and cache posts for the Trails app.
Prerequisites
Installation
- Add the Dependency1
Add the Dependency
Add the Store library to your project's dependencies. Since we're working with a KMP project, we'll add the dependency to the
commonMainsource set.toml[versions] store = "5.1.0" [libraries] store = { module = "org.mobilenativefoundation.store:store5", version.ref = "store" }kotlincommonMain { dependencies { implementation(libs.store) } } - Sync the Project2
Sync the Project
After adding the dependency, sync your project with Gradle to download the Store library.
Building a Store
Now, let’s build a simple Store to fetch and cache posts from our API and cache it for offline access.
- Define the Data Models1
Define the Data Models
Define the models for a post.
- Network Model
- Local Database Model
- Domain Model
Network Model
kotlinpackage org.mobilenativefoundation.trails.backend.models @Serializable data class PostNetworkModel( val id: Int, val creatorId: Int, val caption: String?, val platform: Platform, val createdAt: LocalDateTime, val likesCount: Long, val commentsCount: Long, val sharesCount: Long, val viewsCount: Long, val isSponsored: Boolean, val locationName: String?, val coverUrl: String, val isFavoritedByCurrentUser: Boolean ) - Create the API Interface2
Create the API Interface
Define and implement an interface for your network calls. In this example, we'll use Ktor for HTTP requests.
- APIs
- Implementations
APIs
kotlinpackage org.mobilenativefoundation.trails.xplat.lib.rest.api interface PostOperations { suspend fun getPost(id: Int): PostNetworkModel suspend fun updatePost(post: PostNetworkModel): Boolean } interface TrailsApi: PostOperations - Implement Converters3
Implement Converters
We need to convert between our network model, domain model, and local database model.
- Network to Domain
- Local Database to Domain
- Domain to Local Database
- Domain to Network
Network to Domain
kotlinpackage org.mobilenativefoundation.trails.xplat.lib.market.post.impl.extensions object PostExtensions { // Convert from the network model to the domain model. fun PostNetworkModel.asPost(): Post { return Post( id = this.id, creatorId = this.creatorId, caption = this.caption, createdAt = this.createdAt, likesCount = this.likesCount, commentsCount = this.commentsCount, sharesCount = this.sharesCount, viewsCount = this.viewsCount, isSponsored = this.isSponsored, coverURL = this.coverUrl, platform = this.platform.asPlatform(), locationName = this.locationName, isFavoritedByCurrentUser = this.isFavoritedByCurrentUser ) } } - Set Up the Store Factory4
Set Up the Store Factory
We'll use a factory for creating a
PostStoreinstance.kotlinpackage org.mobilenativefoundation.trails.xplat.lib.market.post.impl.store typealias PostStore = Store<Int, Post> class PostStoreFactory( private val client: PostOperations, private val trailsDatabase: TrailsDatabase, ) { fun create(): PostStore { TODO() } private fun createFetcher(): Fetcher<Int, PostNetworkModel> { TODO() } private fun createSourceOfTruth(): SourceOfTruth<Int, PostEntity, Post> { TODO() } private fun createConverter(): Converter<PostNetworkModel, PostEntity, Post> { TODO() } private fun createUpdater(): Updater<Int, Post, Boolean> { TODO() } private fun createBookkeeper(): Bookkeeper<Int> { TODO() } } - Implement the Fetcher5
Implement the Fetcher
Our Fetcher will interact with the network data source using the
PostOperationsinterface.kotlinpackage org.mobilenativefoundation.trails.xplat.lib.market.post.impl.store private fun createFetcher(): Fetcher<Int, PostNetworkModel> = Fetcher.of { id -> // Fetch post from the network client.getPost(id) ?: throw IllegalArgumentException("Post with ID $id not found.") } - Implement the Source of Truth6
Implement the Source of Truth
Our Source of Truth will delegate to a local SqlDelight database.
kotlinpackage org.mobilenativefoundation.trails.xplat.lib.market.post.impl.store private fun createSourceOfTruth(): SourceOfTruth<Int, PostEntity, Post> = SourceOfTruth.of( reader = { id -> flow { // Query the database for a post emit(trailsDatabase.postQueries.selectPostById(id.toLong())) } }, writer = { _, postEntity -> trailsDatabase.postQueries.insertPost(postEntity) } ) - Implement the Converter7
Implement the Converter
Our Converter will convert between our network model, local database model, and domain model using the
PostExtensionsobject.kotlinpackage org.mobilenativefoundation.trails.xplat.lib.market.post.impl.store private fun createConverter(): Converter<PostNetworkModel, PostEntity, Post> = Converter.Builder<PostNetworkModel, PostEntity, Post>() .fromOutputToLocal { post -> post.asPostEntity() } .fromNetworkToLocal { postNetworkModel -> postNetworkModel.asPost() } .build() - Implement the Updater8
Implement the Updater
Our Updater will make a network call to update the post.
kotlinpackage org.mobilenativefoundation.trails.xplat.lib.market.post.impl.store private fun createUpdater(): Updater<Int, Post, Boolean> = Updater.by( post = { _, updatedPost -> val networkModel = updatedPost.asNetworkModel() val success = client.updatePost(networkModel) if (success) { UpdaterResult.Success.Typed(success) } else { UpdaterResult.Error.Message("Something went wrong.") } } ) - Implement the Bookkeeper9
Implement the Bookkeeper
Our Bookkeeper keeps track of failed syncs to enable eagerly resolving conflicts after local mutations.
kotlinpackage org.mobilenativefoundation.trails.xplat.lib.market.post.impl.store private fun createBookkeeper(): Bookkeeper<Int> = Bookkeeper.by( getLastFailedSync = { id -> trailsDatabase.postBookkeepingQueries .selectMostRecentFailedSync(id).executeAsOneOrNull()?.let { failedSync -> timestampToEpochMilliseconds(timestamp = failedSync.timestamp) } }, setLastFailedSync = { id, timestamp -> try { trailsDatabase.postBookkeepingQueries.insertFailedSync( PostFailedSync( post_id = id, timestamp = epochMillisecondsToTimestamp(timestamp) ) ) true } catch (e: SQLException) { // Handle the exception false } }, clear = { id -> try { trailsDatabase.postBookkeepingQueries.clearByPostId(id) true } catch (e: SQLException) { // Handle the exception false } }, clearAll = { try { trailsDatabase.postBookkeepingQueries.clearAll() true } catch (e: SQLException) { // Handle the exception false } } ) - Build the Store10
Build the Store
Provide the implementations to the Store Builder.
kotlinpackage org.mobilenativefoundation.trails.xplat.lib.market.post.impl.store class PostStoreFactory( private val client: PostOperations, private val trailsDatabase: TrailsDatabase, ) { fun create(): PostStore { return MutableStoreBuilder.from( fetcher = createFetcher(), sourceOfTruth = createSourceOfTruth(), converter = createConverter() ).build( updater = createUpdater(), bookkeeper = createBookkeeper() ) } private fun createFetcher(): Fetcher<Int, PostNetworkModel> {...} private fun createSourceOfTruth(): SourceOfTruth<Int, PostEntity, Post> {...} private fun createConverter(): Converter<PostNetworkModel, PostEntity, Post> {...} private fun createUpdater(): Updater<Int, Post, Boolean> {...} private fun createBookkeeper(): Bookkeeper<Int> {...} }
Using the Store
Now, let’s use the Store to fetch and cache post data for the Trails post detail screen.
- Create a Post Repository1
Create a Post Repository
We'll create a
PostRepositorythat uses thePostStoreto fetch and cache post data. The primary reason for this extra layer is it enables us to extractStorefrom the domain layer as an implementation detail of thePostRepository. It also enables us to add additional methods and strategies to thePostRepositoryin the future.- API
- Implementation
API
kotlinpackage org.mobilenativefoundation.trails.xplat.lib.market.post.api interface PostRepository { suspend fun getPost(id: Int): Post? suspend fun updatePost( postId: Int, likesCount: Long? = null, commentsCount: Long? = null, sharesCount: Long? = null, viewsCount: Long? = null, isFavoritedByCurrentUser: Boolean? = null ): Post } - Define the Post Detail Screen2
Define the Post Detail Screen
Trails is built with a Circuit architecture. Before we can interact with the
PostRepository, we need to define theScreen,State, andEventclasses.kotlinpackage org.mobilenativefoundation.trails.xplat.feat.postDetailScreen.api interface PostDetailScreen : Screen { sealed interface State : CircuitUiState { data class Loaded( val post: Post, val eventSink: (Event) -> Unit, ) : State data object Loading: State } sealed interface Event : CircuitUiEvent { data object Favorite: Event data object Unfavorite: Event } interface UI : CircuitUI<State> interface Presenter : CircuitPresenter<State> } - Implement the Post Detail Presenter3
Implement the Post Detail Presenter
A Circuit Presenter is intended to be the business logic for a screen's UI and a translation layer in front of the data layer. Our
PostDetailScreenPresenterwill use thePostRepositoryto load the post data and update the UI in response to user actions.kotlinpackage org.mobilenativefoundation.trails.xplat.feat.postDetailScreen.impl @Inject class PostDetailScreenPresenter( private val postRepository: PostRepository, @Assisted private val postId: Int ) : PostDetailScreen.Presenter { @Composable override fun present(): PostDetailScreen.State { var post: Post? by remember { mutableStateOf(null) } LaunchedEffect(postId) { post = postRepository.getPost(postId) } return if (post != null) { PostDetailScreen.State.Loaded(post, eventSink = ::handleEvent) } else { PostDetailScreen.State.Loading } } private fun handleEvent(prevState: PostDetailScreen.State, event: PostDetailScreen.Event) { when (event) { is PostDetailScreen.Event.Favorite -> handleFavorite(prevState) is PostDetailScreen.Event.Unfavorite -> handleUnfavorite(prevState) } } private fun handleFavorite(prevState: PostDetailScreen.State) { val nextPost = postRepository.updatePost( postId = postId, isFavoritedByCurrentUser = true, likesCount = prevState.post.likesCount + 1 ) post = nextPost } private fun handleUnfavorite(prevState: PostDetailScreen.State) { val nextPost = postRepository.updatePost( postId = postId, isFavoritedByCurrentUser = false, likesCount = prevState.post.likesCount - 1 ) post = nextPost } } - Display the Post Detail Screen4
Display the Post Detail Screen
kotlinpackage org.mobilenativefoundation.trails.xplat.feat.postDetailScreen.impl @Inject class PostDetailScreenUI : PostDetailScreen.UI { @Composable override fun Content(state: PostDetailScreen.State, modifier: Modifier) { when (state) { is PostDetailScreen.State.Loading -> { LoadingView() } is PostDetailScreen.State.Loaded -> { PostDetailView( post = state.post, onFavorite = { state.eventSink(PostDetailScreen.Event.Favorite) }, onUnfavorite = { state.eventSink(PostDetailScreen.Event.Unfavorite) } ) } } } }
Next Steps
Now that you have built your first Store, it’s time to explore what else is possible:
Deep Dive into Store
Comprehensive explanation of Store internals and the underlying principles.
Use Case Guides
End-to-end implementation guides for common use cases.
Store Cookbook
Our collection of recipes showcasing fun and effective ways of using Store.