swrly 0.4.0-dev.1
swrly: ^0.4.0-dev.1 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: early but usable. Core cache semantics are covered by tests (100% line coverage on
lib/src) and it runs on every platform (mobile, desktop, web).
What you get #
- Cache by
queryKey—staleTime/cacheTime, stale-while-revalidate, request dedupe, GC when a key goes unused. - Retry + backoff — per-query
retry/retryDelayplus client defaults, exponential 1s→30s by default. - Invalidation — by prefix (
invalidateQueries) or predicate (invalidateQueriesWhere). - Optimistic updates with automatic rollback —
MutationBuilder.onMutatereturns a rollback closure that runs for you on failure. keepPreviousData/placeholderData— no loading flash on key change (search / pagination);state.isPlaceholderDatamarks the stand-in.Query/QueryFamilydefinitions — declare a query's key + fetch function once, use it imperatively, declaratively, or for cache control.- Widget-free too —
QueryClientis a complete API on its own.
What changed when lives in CHANGELOG.md; what's next
in doc/ROADMAP.md. This README describes the library as it
is now, and deliberately carries no version numbers — the badge above is the
one place a version appears.
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 #
flutter pub add swrly
Prereleases ship on a -dev.N track and aren't picked up by ^ constraints —
if you want one, pin the exact version listed at the top of
CHANGELOG.md.
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 before it's garbage-collected: the countdown starts when its lastQueryBuilderunsubscribes, and any later access (afetchQuery, a read) restarts it.
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: ...,
)
Using with your state management #
swrly is scoped to server state (data your app fetched from an API and
caches). It doesn't own your client state (form inputs, filters, navigation)
— your existing state-management library keeps doing that. The five patterns
below live in example/lib/patterns/ as runnable
Flutter code; each one implements the same posts list + detail + optimistic
create demo, differing only in how client state is threaded to the UI.
| Pattern | Client state via | Where to look |
|---|---|---|
StatefulWidget (setState) |
setState |
patterns/plain |
| Provider | ChangeNotifier (no List<Post> inside!) |
patterns/provider |
| Riverpod | StateProvider — swrly runs beside, not underneath |
patterns/riverpod |
| Bloc / Cubit | Cubit<String> — repo calls Query.fetch() |
patterns/bloc |
| flutter_hooks | useState + useSwrlyQuery from swrly_hooks |
patterns/hooks |
The one rule that holds across all five: the state-management library
never holds List<Post>, isLoading, or error for fetched data. Those
live in the swrly cache. See doc/CONVENTIONS.md for
the full ruleset.
A note on flutter_hooks #
swrly core deliberately doesn't depend on flutter_hooks — Dart has
no peer-dependency story, so a hard dep would burden every non-hook
user. The hook bindings ship as a separate companion package,
swrly_hooks, mirroring the flutter_bloc / hooks_riverpod split:
flutter pub add swrly swrly_hooks
import 'package:swrly_hooks/swrly_hooks.dart';
class PostsPage extends HookWidget {
@override
Widget build(BuildContext context) {
final state = useSwrlyQuery(postsQuery);
if (state.isLoading && !state.hasData) return const CircularProgressIndicator();
return PostsView(state.data!);
}
}
The package handles the subscription lifecycle, canonical key hashing
and unhandled-async details a hand-rolled snippet routinely gets wrong.
See packages/swrly_hooks/ and the hooks
pattern example.
Using with AI assistants #
The repo ships an AGENTS.md rulebook so AI coding
assistants (Claude Code, Cursor, Aider, GitHub Copilot, Windsurf, ...)
follow the same swrly conventions your codebase already does — for
brand-new feature work as much as for refactors. Point your assistant
at it once and it'll route through swrly by default when writing any
API/server-state code.
Pick whichever fits your tool:
| Tool | How |
|---|---|
| Claude Code | Append this line to your project's CLAUDE.md: See rules at https://raw.githubusercontent.com/redhotsixbull/swrly/main/AGENTS.md (Claude fetches it on demand) |
| Cursor | Add the same line to .cursorrules, or drop the AGENTS.md file directly into your project root |
| Aider / Windsurf / Continue / Copilot Workspace | Drop AGENTS.md into your project root — most tools auto-detect this file (see agentsmd.dev) |
| Any assistant with a system-prompt slot | Paste the raw URL into your instructions |
Copy the URL:
https://raw.githubusercontent.com/redhotsixbull/swrly/main/AGENTS.md
Or download the file directly:
curl -O https://raw.githubusercontent.com/redhotsixbull/swrly/main/AGENTS.md
Once installed, ask your assistant anything from "add a posts screen"
to "clean up this notifier" — it'll follow the conventions in
AGENTS.md inline, and fetch the deeper .claude/skills/*/SKILL.md
prompts from raw GitHub when the task warrants a step-by-step
procedure (init, refactor-*, audit).
Using swrly without widgets #
QueryBuilder is the convenient way to read a query, not the only one. The
cache lives in QueryClient, so you can drive it imperatively from a repository
layer, a route guard, a button handler or a background job — no widget involved:
// Just this one API call — but deduped, cached, staleTime-aware and retried.
final posts = await QueryClient.instance.fetchQuery<List<Post>>(
key: const ['posts'],
fn: () => api.getPosts(),
staleTime: const Duration(seconds: 30),
);
This is not a bypass of the cache. fetchQuery is the exact call
QueryBuilder makes internally, so:
- a fresh entry is returned from cache without touching the network,
- concurrent calls for the same key share one in-flight request,
retry/retryDelayapply the same way,- and any
QueryBuildermounted on that key updates from this call — the imperative and declarative APIs are two doors into the same cache.
The three ways to read a query #
| API | Use it for | |
|---|---|---|
| Declarative | QueryBuilder<T> / MutationBuilder<T, V> |
widgets |
| Imperative | client.fetchQuery<T>(key:, fn:) → Future<T> |
repositories, prefetch, handlers, background work |
| Observe | client.observe<T>(key) → Stream<QueryState<T>>, client.stateOf<T>(key) |
bridging into Riverpod / Bloc / a service layer |
Common imperative patterns:
// Prefetch before pushing a route — the detail screen then paints from cache.
await QueryClient.instance.fetchQuery<Post>(
key: ['post', id],
fn: () => api.getPost(id),
);
if (context.mounted) Navigator.of(context).pushNamed('/post/$id');
// Synchronous peek — no fetch, no await.
final cached = QueryClient.instance.getQueryData<List<Post>>(['posts']);
final state = QueryClient.instance.stateOf<List<Post>>(['posts']);
// Watch a key from outside the widget tree.
final sub = QueryClient.instance
.observe<List<Post>>(['posts'])
.listen((state) => print('posts -> ${state.status}'));
Heads-up on
observe: it hands you the entry's broadcast stream but does not register a subscriber, so it doesn't hold the entry againstcacheTimeGC the way a mountedQueryBuilderdoes. For a long-lived non-widget consumer, keep the entry alive by re-fetchQuery-ing it, or wait for theQueryObserverAPI on the roadmap, which will own that lifecycle.
Define a query once — Query / QueryFamily #
Both APIs above need a (key, fn) pair, so without somewhere to put it you
re-type that pair at every call site — and a typo in the key is a silent cache
miss, not a compile error. A Query is that pair, named once:
// repository layer — declare it once
final postsQuery = Query<List<Post>>(
key: const ['posts'],
fn: () => api.getPosts(),
staleTime: const Duration(seconds: 30),
);
final posts = await postsQuery.fetch(); // imperative
QueryBuilder.of(postsQuery, builder: (ctx, state, _) => ...); // declarative
postsQuery.invalidate(); // cache control
A Query holds no state — the cache still lives in QueryClient. It's a
value object, so two instances with the same key address the same entry.
On a Query |
|
|---|---|
fetch() |
through the cache (fresh → no network, in-flight → joined) |
refetch() |
past staleTime — the pull-to-refresh call |
data / state |
synchronous reads; never fetch |
stream |
Stream<QueryState<T>> for this key |
setData(v) |
optimistic write |
invalidate() / remove() |
this key only (see below) |
copyWith(…) |
one-off option override at a call site |
Parameterised queries — QueryFamily #
final postQuery = QueryFamily<Post, int>(
prefix: const ['post'],
fn: (id) => api.getPost(id),
);
await postQuery(3).fetch(); // key ['post', 3]
postQuery(3).invalidate(); // just that one
postQuery.invalidateAll(); // every ['post', …]
Keys are always [...prefix, ...argument], so invalidateAll() / removeAll()
are correct by construction. When the argument isn't a primitive, map it to
primitives with argKey instead of letting the key fall back to toString():
final pageQuery = QueryFamily<PostPage, (int, String)>(
prefix: const ['posts'],
argKey: (a) => [a.$1, a.$2], // ['posts', 2, 'flutter']
fn: (a) => api.getPosts(page: a.$1, q: a.$2),
);
Exact vs prefix #
Query.invalidate() and Query.remove() are exact — a Query names one
entry, so invalidating ['posts'] will not touch ['posts', 'page', 2]. The
prefix behaviour is still there when you want it: family.invalidateAll(),
client.invalidateQueries(prefix), client.removeQueries(prefix).
Why not just inline it? #
Besides deduplicating the key: QueryBuilder's queryFn is captured for later
invalidation refetches, and an inline closure changes identity on every
build. A Query's fn is a stable field, so the closure a refetch runs is
always the one you wrote.
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'),
),
)
Optimistic update with automatic rollback — onMutate runs before
the request and returns a rollback closure that swrly runs for you if it fails:
MutationBuilder<Post, String>(
mutationFn: createPost,
onMutate: (title) {
final prev = QueryClient.instance.getQueryData<List<Post>>(['posts']) ?? [];
QueryClient.instance.setQueryData<List<Post>>(
['posts'], [Post.draft(title), ...prev]); // show it instantly
return () => QueryClient.instance
.setQueryData<List<Post>>(['posts'], prev); // auto-rollback on error
},
onSettled: (_) => QueryClient.instance.invalidateQueries(['posts']),
builder: ...,
)
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 driven by subscription + last access. Use dio for
transport; swrly for state.
API at a glance #
QueryClient— the cache, and a complete API on its own (see Using swrly without widgets).fetchQuery(imperative fetch),observe/stateOf(stream + synchronous read),invalidateQueries(prefix)/invalidateQueriesWhere((key) => bool),setQueryData/getQueryData,removeQueries/removeQueriesWhere((key) => bool),clear.Query<T>/QueryFamily<T, A>— a reusable definition of a query (key + fn + options).fetch/refetch,data/state/stream,setData,invalidate/remove(exact),copyWith; families addkeyFor,invalidateAll/removeAll.QueryBuilder<T>— subscribes a widget to a key; rebuilds on state changes; auto-unsubscribes (drives GC).enabled,refetchOnResume,retry/retryDelay,keepPreviousData/placeholderData. Build it from a definition withQueryBuilder.of(query, builder: ...).MutationBuilder<T, V>—mutate(vars)withonMutate(optimistic + rollback) /onSuccess/onError/onSettled.QueryState<T>—isLoading/isSuccess/isError,data,hasData,error,isFetching(background refetch while data is present), andisPlaceholderData.
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).
Performance #
Measured on an Apple M3 Pro (flutter test, JIT — an AOT release build is
faster):
| Cache op | throughput | notes |
|---|---|---|
getQueryData |
~1.8–3.2 M/sec | hash-map lookup |
setQueryData |
~160–240 K/sec | allocates the entry + stream + GC timer |
invalidateQueries fan-out |
~0.3 µs / entry | linear; sub-ms for hundreds–thousands of keys |
Reads are effectively free; writes do real per-entry work. Invalidation scales linearly with the number of cached entries (≈9 ms across 10 K, ≈32 ms across 100 K) — well beyond what a normal app holds.
Run it yourself: the example app has a Stress test screen (speed icon in the
AppBar) with a live FPS / build / raster / jank readout, a cache-ops
micro-benchmark, and hundreds of live QueryBuilders under continuous
invalidation.
Known limitations #
- In-memory only — no disk persistence / offline cache yet.
- No infinite/paginated query helper, no window-focus refetch (app-resume refetch is supported), no devtools.
- No non-widget
QueryObserveryet —observe()gives you the stream but does not hold the entry againstcacheTimeGC (see the note above). - Cache keys must be primitives / lists / maps (structural equality); custom
objects fall back to
toString().QueryFamily.argKeyis the escape hatch — map the argument to primitives yourself.
Where this is going #
swrly grows with real use — the point is to erase the "hand-rolled" gaps so the
honest comparison keeps tilting in swrly's favour.
What's already here is listed under What you get; what landed
when is in CHANGELOG.md.
Still ahead:
- Robustness — request cancellation when the last subscriber leaves
(threads an abort token into
queryFn), typed error surfaces. - Bigger features — infinite / paginated queries, window/online refetch triggers.
- Ergonomics — a non-widget
QueryObserverthat owns its subscription (soobserveno longer leaks the GC question).useQueriesis intentionally skipped as too React-flavored; a type-safe record combinator is the preferred path. (flutter_hooksuseSwrlyQuery/useSwrlyMutationalready ship in theswrly_hookscompanion package.) - Persistence — a pluggable adapter interface (hive / shared_preferences / drift) for offline-first caching.
- Ecosystem — a DevTools panel, and Riverpod / Bloc
AsyncValuebridges.
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