# gleam_query
A type-safe async data fetching and caching library for Gleam, inspired by [TanStack Query](https://tanstack.com/query).
Tracks the full lifecycle of remote data — `NotAsked → Loading → Loaded | Failed` — with stale-while-revalidate built in. The core is framework-agnostic pure Gleam; a first-class [Lustre](https://lustre.build) integration is included.
## Installation
```sh
gleam add gleam_query
```
## Core concept
Every piece of remote data is an `Entry`:
```gleam
pub type Entry(data, err) {
NotAsked // no fetch attempted yet
Loading(stale: Option(data)) // fetching — stale data available for display
Loaded(data: data, at: Int) // succeeded — `at` is Unix ms timestamp
Failed(err: err) // last fetch failed
}
```
`gleam_query` never stores data for you. You own the cache — a plain record in your app model — and use `Entry` as the value type. This keeps the cache fully typed and under your control.
## Usage with Lustre
### 1. Define your cache
```gleam
import gleam/dict.{type Dict}
import gleam_query.{type Entry}
pub type Cache {
Cache(
contacts: Dict(String, Entry(List(Contact), ApiError)),
contact: Dict(Int, Entry(Contact, ApiError)),
)
}
```
### 2. Add it to your app model
```gleam
pub type Model {
Model(page: Page, cache: Cache)
}
```
### 3. Query data
Call `gleam_query/lustre.query` inside `update` whenever you need remote data. It checks staleness and fires the fetch only when necessary.
```gleam
import gleam_query/lustre as gq
// in your Msg type:
pub type Msg {
UserNavigatedToContacts(key: String)
CacheGotContacts(key: String, result: Result(List(Contact), ApiError))
}
// in update:
UserNavigatedToContacts(key) -> {
let entry =
dict.get(model.cache.contacts, key)
|> result.unwrap(gleam_query.NotAsked)
let #(new_entry, eff) = gq.query(
entry: entry,
stale_ms: 30_000,
fetch: contact_service.list(params),
on_result: fn(result) { CacheGotContacts(key, result) },
)
let cache =
Cache(..model.cache,
contacts: dict.insert(model.cache.contacts, key, new_entry),
)
#(Model(..model, cache: cache), eff)
}
```
- **Fresh entry** → no fetch, `effect.none()` returned immediately.
- **Stale or missing** → entry moves to `Loading(stale_data)` so the UI can
keep displaying previous data, and the fetch effect fires.
### 4. Handle the result
```gleam
CacheGotContacts(key, result) -> {
let entry = gq.record(result)
let contacts = dict.insert(model.cache.contacts, key, entry)
#(Model(..model, cache: Cache(..model.cache, contacts: contacts)), effect.none())
}
```
### 5. Render with stale data
`get_data` returns data for both `Loaded` and `Loading(Some(_))`, so the UI
can always show something while a background revalidation is in progress.
```gleam
case gleam_query.get_data(entry) {
option.Some(contacts) -> view_table(contacts, loading: entry == gleam_query.Loading)
option.None -> view_spinner()
}
```
### 6. Invalidate after a mutation
After a write, mark related cache entries stale. The next navigation will
trigger a background refetch automatically.
```gleam
UserSavedContact -> {
let contacts =
dict.map_values(model.cache.contacts, fn(_, e) { gleam_query.invalidate(e) })
#(
Model(..model, cache: Cache(..model.cache, contacts: contacts)),
save_contact_effect(),
)
}
```
## API reference
### `gleam_query` — pure core
| Symbol | Description |
|---|---|
| `type Entry(data, err)` | The four-state lifecycle type |
| `is_stale(entry, stale_ms, now_ms)` | `True` when entry needs a fresh fetch |
| `get_data(entry)` | Returns data from `Loaded` or `Loading(Some(_))` |
| `invalidate(entry)` | Marks loaded data stale; preserves it for display |
| `record(result, now_ms)` | Builds an `Entry` from a `Result` and timestamp |
| `always_stale` | Constant `0` — always refetch |
| `never_stale` | Constant `max_int` — fetch once, never expire |
### `gleam_query/lustre` — Lustre integration
| Function | Description |
|---|---|
| `query(entry:, stale_ms:, fetch:, on_result:)` | Fetches if stale, returns `#(Entry, Effect(msg))` |
| `record(result)` | Records a fetch result timestamped to `Date.now()` |
## Design notes
**Why own your cache?**
Gleam's type system makes a single heterogeneous cache (like react-query's `QueryCache`) awkward without `Dynamic`. Owning a typed record is idiomatic, explicit, and zero overhead.
**Why no automatic background refetch on focus/reconnect?**
These are opt-in Lustre effect subscriptions that belong in your app. `gleam_query` stays small and composable so you wire them how you like.
**Erlang target?**
`gleam_query/lustre` uses `Date.now()` via JavaScript FFI and requires `target = "javascript"`. The core `gleam_query` module is pure Gleam with no FFI; an Erlang-compatible wrapper is feasible if there is demand.
## Licence
MIT