Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 4 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ dependencies {
}
```

> Replace `<version>` with the version from your `build.gradle.kts`.
> Replace `<version>` with the version from your `build.gradle.kts`. Currently 1.0.0-alpha01

---

Expand Down Expand Up @@ -89,11 +89,12 @@ A sample Jetpack Compose app with:
### StatelyFetch

```kotlin
val fetch = StatelyFetch(
val fetch = StatelyFetch<String, String>(
fetcher = { payload -> api.loadData(payload) },
revalidateInterval = 5000L,
lazy = false,
initialData = null
initialData = null,
initialPayload = "",
)
```

Expand Down Expand Up @@ -163,13 +164,6 @@ StatelyFetchBoundary(

---

## Roadmap

- [ ] Auto cancellation
- [ ] Debouncing

---

## Contributing

This library is still under active development. PRs and feedback are welcome.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ fun StatelyFetchBoundaryExampleScreen() {
.padding(padding)
.padding(16.dp)
) {
StatelyFetchBoundary(
fetcher = {
StatelyFetchBoundary<String, String?>(
fetcher = { payload ->
delay(1500)
"Data from StatelyFetchBoundary"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import kotlinx.coroutines.delay
@Composable
fun StatelyFetchContentExampleScreen() {
val scope = rememberCoroutineScope()
val statelyFetch = rememberStatelyFetch(
val statelyFetch = rememberStatelyFetch<String>(
fetcher = {
delay(2000)
"This is data from StatelyFetchContent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ fun StatelyFetchExampleScreen() {
var currentConfig by remember { mutableStateOf<FetchConfig?>(null) }
var simulationVersion by remember { mutableIntStateOf(0) }

var payloadInput by remember { mutableStateOf<String?>(null) }

Scaffold(
topBar = {
TopAppBar(title = { Text("StatelyFetch Example") })
Expand Down Expand Up @@ -94,13 +96,24 @@ fun StatelyFetchExampleScreen() {
modifier = Modifier.fillMaxWidth()
)

OutlinedTextField(
value = payloadInput.orEmpty(),
onValueChange = {
payloadInput = it.ifBlank { null }
},
label = { Text("Initial payload value") },
keyboardOptions = KeyboardOptions.Default,
modifier = Modifier.fillMaxWidth()
)

Button(onClick = {
currentConfig = FetchConfig(
simulateError = simulateError,
lazy = lazy,
initiallyLoading = initiallyLoading,
revalidateInterval = revalidateIntervalInput,
initialData = initialData
initialData = initialData,
payload = payloadInput
)
simulationVersion++
}) {
Expand All @@ -121,21 +134,22 @@ fun StatelyFetchExampleScreen() {

@Composable
private fun FetchSimulation(config: FetchConfig) {
var counter by remember(config) { mutableIntStateOf(0) }

val scope = rememberCoroutineScope()

val statelyFetch = rememberStatelyFetch(
fetcher = {
var currentPayload by remember(config) { mutableStateOf(config.payload) }

val statelyFetch = rememberStatelyFetch<String, String>(
fetcher = { payload ->
delay(3000) // simulate network delay
if (config.simulateError) throw Exception("Simulated Error")
"Fetched Data: ${counter++}"
"Fetched Data (payload=${payload ?: "none"})"
},
scope = scope,
revalidateInterval = config.revalidateInterval,
initiallyLoading = config.initiallyLoading,
initialData = config.initialData,
lazy = config.lazy
lazy = config.lazy,
initialPayload = config.payload
)

val state by statelyFetch.state.collectAsState()
Expand All @@ -144,17 +158,40 @@ private fun FetchSimulation(config: FetchConfig) {
Text("Loading: ${state.loading}")
Text("Data: ${state.data ?: "null"}")
Text("Error: ${state.error?.message ?: "none"}")

HorizontalDivider()

Button(onClick = {
statelyFetch.revalidate()
}) {
Text("Fetch/revalidate")
}

Button(onClick = {
statelyFetch.revalidateDebounced(debounce = 3000)
}) {
Text("Fetch/revalidate with debounce")
}

HorizontalDivider()


OutlinedTextField(
value = currentPayload.orEmpty(),
onValueChange = {
currentPayload = it.ifBlank { null }
statelyFetch.revalidate(it.ifBlank { null }) // triggers fetch + tracks new payload
},
label = { Text("Change Payload") },
modifier = Modifier.fillMaxWidth()
)
}

private data class FetchConfig(
val simulateError: Boolean,
val lazy: Boolean,
val initiallyLoading: Boolean,
val revalidateInterval: Long?,
val initialData: String?
val initialData: String?,
val payload: String?
)
96 changes: 89 additions & 7 deletions stately/src/commonMain/kotlin/dev/voir/stately/StatelyFetch.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,41 @@ package dev.voir.stately
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

/**
* Represents the current result state of a fetch operation.
*
* @param Data The type of data being fetched.
* @param Payload The type of payload used to trigger the fetch.
* @property payload The payload used in the current request.
* @property data The successfully fetched data (null if failed or not yet loaded).
* @property loading Whether a fetch is currently in progress.
* @property error The exception thrown during fetch (if any).
*/
data class StatelyFetchResult<Data, Payload>(
val payload: Payload?,
val data: Data?,
val loading: Boolean,
val error: Throwable? = null,
)

/**
* A utility class for managing async data fetching with automatic state tracking,
* manual and debounced revalidation, and optional interval-based refresh.
*
* @param fetcher A suspending function that performs the data fetch, accepting a payload.
* @param scope The coroutine scope to launch fetch operations in (defaults to IO).
* @param revalidateInterval If provided, will re-trigger fetch on this interval in ms.
* @param lazy If true, the fetch will not start automatically on creation.
* @param initialData Optional initial data to display before fetching.
* @param initialPayload Optional payload to use for the initial fetch.
* @param initiallyLoading Whether to mark the state as loading immediately.
*/
class StatelyFetch<Data, Payload>(
private val fetcher: suspend (payload: Payload?) -> Data,
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO),
Expand All @@ -24,49 +47,108 @@ class StatelyFetch<Data, Payload>(
initialPayload: Payload? = null,
initiallyLoading: Boolean = true,
) {
// True if a fetch operation is in progress.
private var revalidating: Boolean = false

// The current active fetch coroutine (cancellable).
private var fetchJob: Job? = null


// The current scheduled debounce job (used to delay fetch execution).
private var debounceJob: Job? = null

// The most recent payload used or queued for fetch.
private var payload = initialPayload

// Backing state flow for consumers to observe fetch result updates.
private val _state = MutableStateFlow(
StatelyFetchResult(
payload = initialPayload,
loading = initiallyLoading,
data = initialData
)
)

/**
* Public immutable state flow exposing the fetch result.
* Observers can collect this to react to loading, error, and data changes.
*/
val state: StateFlow<StatelyFetchResult<Data, Payload>>
get() = _state

init {
// Trigger initial fetch immediately unless lazy is true.
if (!lazy) {
revalidate()
revalidate(initialPayload)
}

// If a revalidateInterval is set, start a loop that periodically refetches.
if (revalidateInterval != null) {
scope.launch {
while (true) {
delay(revalidateInterval)
revalidate()
revalidate(payload)
}
}
}
}

fun revalidate(payload: Payload? = null) {
if (!revalidating) {
/**
* Immediately revalidates the data with an optional payload override.
* Cancels any pending debounce job and any in-flight fetch.
*
* @param payload The payload to use for this fetch (defaults to last used).
*/
fun revalidate(payload: Payload? = this.payload) {
debounceJob?.cancel()

this.payload = payload
doRevalidate(payload)
}


/**
* Schedules a debounced revalidation. If called again before the debounce delay,
* the previous job is cancelled and the timer restarts.
*
* Useful for user input scenarios (e.g., text field filters).
*
* @param payload The payload to use (defaults to current).
* @param debounce How long to wait (in milliseconds) before triggering fetch.
*/
fun revalidateDebounced(payload: Payload? = this.payload, debounce: Long = 1000) {
this.payload = payload
debounceJob?.cancel()
debounceJob = scope.launch {
if (debounce > 0) {
delay(debounce)
}
doRevalidate(payload)
}
}

/**
* Launches a fetch coroutine and updates state accordingly.
* Cancels any currently running fetch.
*
* @param payload The payload to pass to the fetcher function.
*/
private fun doRevalidate(payload: Payload? = null) {
revalidating = true
fetchJob?.cancel() // Ensure only one fetch at a time.
fetchJob = scope.launch {
revalidating = true

scope.launch {
// Mark state as loading with new payload.
_state.value = state.value.copy(loading = true, error = null, payload = payload)

try {
// Perform the fetch.
val data = fetcher(payload)
// Update state with result.
_state.value = StatelyFetchResult(payload = payload, data = data, loading = false)
} catch (e: Exception) {
e.printStackTrace()
// Keep previous data and payload on error
// On error, retain previous data and set error field.
_state.value = _state.value.copy(error = e, loading = false)
} finally {
revalidating = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO

@Composable
fun <Data, Payload : Any?> rememberStatelyFetch(
fetcher: suspend (payload: Payload?) -> Data,
scope: CoroutineScope = CoroutineScope(Dispatchers.IO),
revalidateInterval: Long? = null,
initiallyLoading: Boolean = true,
lazy: Boolean = false,
initialData: Data? = null,
initialPayload: Payload? = null,
autoRevalidateOnPayloadChange: Boolean = true,
) = remember {
StatelyFetch(
fetcher = fetcher,
scope = scope,
revalidateInterval = revalidateInterval,
initialData = initialData,
initiallyLoading = initiallyLoading,
initialPayload = initialPayload,
lazy = lazy,
)
}

@Composable
fun <Data> rememberStatelyFetch(
fetcher: suspend () -> Data,
Expand All @@ -17,13 +39,16 @@ fun <Data> rememberStatelyFetch(
lazy: Boolean = false,
initialData: Data? = null,
) = remember {
StatelyFetch<Data, Unit>(
fetcher = { fetcher() },
StatelyFetch(
fetcher = {
fetcher()
},
scope = scope,
revalidateInterval = revalidateInterval,
initialData = initialData,
initiallyLoading = initiallyLoading,
lazy = lazy
initialPayload = null,
lazy = lazy,
)
}

Expand Down
Loading
Loading