swrly 0.1.0
swrly: ^0.1.0 copied to clipboard
Server-state cache for Flutter — dedupe, cache by query key, stale-while-revalidate. A TanStack Query for Flutter.
swrly #
[swrly — server-state cache for Flutter]
Async data-fetching & server-state cache for Flutter — dedupe requests, cache by query key, serve instantly while revalidating in the background, and invalidate on mutations. Inspired by TanStack Query and SWR.
Status:
0.1.0— early but usable. Core cache semantics are covered by tests and it runs on every platform (mobile, desktop, web).
Why swrly? #
Server state — the data you fetch from an API — behaves differently from the
client state your app owns (form inputs, toggles, navigation). It's shared,
it goes stale, and it wants deduping, background refetching, and invalidation
after writes. swrly is a small, focused cache for exactly that, modelled on
TanStack Query.
Being honest about where it fits:
- vs
FutureBuilder— no contest:FutureBuilderhas no cache (it re-runs on rebuild) and can't dedupe, share, or invalidate.swrlywins here easily. - vs Riverpod / Bloc — these are excellent, and Riverpod's
FutureProvider.family/AsyncNotifiercan cache server state andref.invalidateit. Soswrlyisn't "the only way." Its pitch is narrower and honest: a dedicated server-state cache with stale-while-revalidate built in (staleTime/cacheTime, request dedupe, optimistic writes), a familiar TanStack-Query API, and no framework to adopt — it's just an object you can drop into any app (including a Riverpod/Bloc one). - vs a dio cache interceptor — that caches HTTP responses by URL;
swrlycaches app state by logicalqueryKeyand also gives you loading/error/isFetching, invalidation, and optimistic updates (see the table below).
If you already live in Riverpod and are happy hand-rolling staleness/refetch on
async providers, you may not need this. If you want that behaviour out of the
box — or you're not on Riverpod — swrly is for you.
You don't wrap dio — you just pass your call #
swrly doesn't fetch anything itself and it's not an interceptor. You keep
using dio (or http, GraphQL, Firestore…) exactly as-is and hand swrly the
call as a queryFn plus a queryKey; it caches the result under that key. The
example app uses dio against a real API, with a live request counter so you
can see the cache working:
[Opening a post fetches once; re-opening it is a cache hit (the request counter doesn't move); a different post fetches once]
The dio requests counter only moves on a real network call — watch it in the
breakdown below: re-opening the same post serves the detail from cache (0
requests, instant), while a different post id is a separate cache entry:
| Real fetch (dio) | Re-open same key → cache hit | Different key → new fetch |
|---|---|---|
| [list] | [cache hit] | [keyed] |
cd example && flutter run # mobile / desktop
cd example && flutter run -d chrome # web (real dio calls to a public API)
Install #
dependencies:
swrly: ^0.1.0
How it works #
QueryBuilder(queryKey, queryFn, staleTime)
│
▼
QueryClient looks up queryKey
│
fresh in cache? ──yes──► serve cached data instantly (no queryFn call)
│no
▼
a request already in flight for this key? ──yes──► await it (dedupe)
│no
▼
run queryFn (your dio call) ──► store result under queryKey ──► emit to widgets
staleTime— how long data counts as fresh. Within it, re-reads are served from cache with no network call. After it, the next read refetches (while still showing the cached data — stale-while-revalidate).cacheTime— how long an unused entry stays in memory after its lastQueryBuilderunsubscribes, before it's garbage-collected.
Quick start #
final dio = Dio();
QueryBuilder<List<Post>>(
queryKey: const ['posts'],
queryFn: () async => (await dio.get('/posts')).data
.map<Post>(Post.fromJson).toList(),
staleTime: const Duration(seconds: 30),
builder: (context, state, refetch) {
if (state.isLoading && !state.hasData) return const CircularProgressIndicator();
if (state.isError && !state.hasData) return Text('Error: ${state.error}');
return PostList(state.data!, refreshing: state.isFetching, onRefresh: refetch);
},
)
Detail keyed by id — re-opening the same post is instant, a different id fetches once:
QueryBuilder<Post>(
queryKey: ['post', id], // separate cache entry per id
queryFn: () async => Post.fromJson((await dio.get('/posts/$id')).data),
staleTime: const Duration(minutes: 1),
builder: ...,
)
Mutations #
MutationBuilder<Post, String>(
mutationFn: (title) async =>
Post.fromJson((await dio.post('/posts', data: {'title': title})).data),
onSuccess: (post, _) {
// Optimistic write — show it instantly with no refetch:
final current = QueryClient.instance.getQueryData<List<Post>>(['posts']) ?? [];
QueryClient.instance.setQueryData<List<Post>>(['posts'], [post, ...current]);
// …or invalidate to refetch from the server:
// QueryClient.instance.invalidateQueries(['posts']);
},
builder: (context, mutate, state) => FilledButton(
onPressed: state.isLoading ? null : () => mutate(title),
child: Text(state.isLoading ? 'Saving…' : 'Save'),
),
)
How it compares #
vs FutureBuilder #
FutureBuilder |
swrly |
|
|---|---|---|
| Caching | ❌ re-runs the future on rebuild | ✅ cached by queryKey |
| Dedupe identical requests | ❌ | ✅ shares one in-flight request |
| Stale-while-revalidate | ❌ | ✅ staleTime |
| Invalidate after a write | ❌ (manual) | ✅ invalidateQueries |
| Share data across widgets | ❌ each has its own future | ✅ same key = same cache |
vs Riverpod / Bloc / Provider #
Fair comparison: Riverpod can do server-state caching —
FutureProvider.family caches by args and ref.invalidate re-runs it. So this
isn't "Riverpod can't." It's about how much is built in vs hand-rolled, and
whether you want a dedicated tool.
| Riverpod async providers | swrly |
|
|---|---|---|
| Cache keyed by request args | ✅ .family |
✅ queryKey |
| Invalidate | ✅ ref.invalidate |
✅ invalidateQueries (prefix) |
staleTime / stale-while-revalidate |
hand-rolled | ✅ built-in |
| Request dedupe across widgets | ✅ | ✅ |
Optimistic setQueryData + GC by subscription |
hand-rolled | ✅ built-in |
| Requires adopting the framework | yes (providers everywhere) | no — just an object |
Rule of thumb: already all-in on Riverpod and happy hand-rolling staleness?
you may not need swrly. Want TanStack-style server-state semantics out of the
box, or you're not on Riverpod? reach for swrly. You can also use both —
Riverpod for client state, swrly for fetched data (its QueryClient is just
an object you expose however you like).
vs a dio cache interceptor #
A dio cache interceptor caches at the HTTP layer (by URL). swrly caches at
the app-state layer (by queryKey), so it also gives you loading/error
state, isFetching, dedupe across widgets, invalidateQueries, optimistic
setQueryData, and GC tied to widget lifecycle. Use dio for transport; swrly
for state.
API at a glance #
QueryClient— the cache.fetchQuery,invalidateQueries(prefix),setQueryData/getQueryData,removeQueries,clear.QueryBuilder<T>— subscribes a widget to a key; rebuilds on state changes; auto-unsubscribes (drives GC).enabled,refetchOnResume.MutationBuilder<T, V>—mutate(vars)withonSuccess/onError/onSettled.QueryState<T>—isLoading/isSuccess/isError,data,error, andisFetching(a background refetch while data is present).
See doc/API.md and doc/SPEC.md.
When not to use this #
- Pure client state (form fields, toggles) → Riverpod / Bloc /
setState. - A single fetch you never re-read or cache →
FutureBuilderis fine. - Offline-first persistence → not yet (in-memory only; see limitations).
Known limitations (0.1.x) #
- In-memory only — no disk persistence / offline cache yet.
- No infinite/paginated query helper, no automatic retry/backoff, no window-focus refetch (app-resume refetch is supported), no devtools.
setQueryDataoptimistic writes have no built-in rollback helper — handleonErroryourself.- Cache keys must be primitives / lists / maps (structural equality); custom
objects fall back to
toString().
Where this is going #
swrly is early (0.1.0) and will grow with real use. The plan, roughly in
order — the point is to erase the "hand-rolled" gaps above so the honest
comparison keeps tilting in swrly's favour:
- Ergonomics first —
useQuery/useMutationforflutter_hooks, a predicate form ofinvalidateQueries, and a non-widgetQueryObserver. - Robustness — configurable retry + backoff, typed error surfaces, and request cancellation when the last subscriber leaves.
- Bigger features — infinite / paginated queries, first-class optimistic updates with rollback, and window/online refetch triggers.
- Persistence — a pluggable adapter interface (hive / shared_preferences / drift) for offline-first caching.
- Ecosystem — a DevTools panel to inspect the cache, and Riverpod / Bloc
bridges (
AsyncValueadapters) so it composes cleanly with what you already use.
Full detail and later milestones in doc/ROADMAP.md.
Feedback and issues are very welcome — the roadmap is driven by what people
actually hit.
License #
MIT