swrly 0.3.1 copy "swrly: ^0.3.1" to clipboard
swrly: ^0.3.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

pub package license: MIT

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 queryKeystaleTime / cacheTime, stale-while-revalidate, request dedupe, GC when a key goes unused.
  • Retry + backoff — per-query retry / retryDelay plus client defaults, exponential 1s→30s by default.
  • Invalidation — by prefix (invalidateQueries) or predicate (invalidateQueriesWhere).
  • Optimistic updates with automatic rollbackMutationBuilder.onMutate returns a rollback closure that runs for you on failure.
  • keepPreviousData / placeholderData — no loading flash on key change (search / pagination); state.isPlaceholderData marks the stand-in.
  • Query / QueryFamily definitions — declare a query's key + fetch function once, use it imperatively, declaratively, or for cache control.
  • Widget-free tooQueryClient is 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: FutureBuilder has no cache (it re-runs on rebuild) and can't dedupe, share, or invalidate. swrly wins here easily.
  • vs Riverpod / Bloc — these are excellent, and Riverpod's FutureProvider.family / AsyncNotifier can cache server state and ref.invalidate it. So swrly isn'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; swrly caches app state by logical queryKey and 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 last QueryBuilder unsubscribes, and any later access (a fetchQuery, 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 / retryDelay apply the same way,
  • and any QueryBuilder mounted 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 against cacheTime GC the way a mounted QueryBuilder does. For a long-lived non-widget consumer, keep the entry alive by re-fetchQuery-ing it, or wait for the QueryObserver API 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 rollbackonMutate 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 add keyFor, 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 with QueryBuilder.of(query, builder: ...).
  • MutationBuilder<T, V>mutate(vars) with onMutate (optimistic + rollback) / onSuccess / onError / onSettled.
  • QueryState<T>isLoading / isSuccess / isError, data, hasData, error, isFetching (background refetch while data is present), and isPlaceholderData.

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 cacheFutureBuilder is 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 QueryObserver yet — observe() gives you the stream but does not hold the entry against cacheTime GC (see the note above).
  • Cache keys must be primitives / lists / maps (structural equality); custom objects fall back to toString(). QueryFamily.argKey is 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 featuresinfinite / paginated queries, window/online refetch triggers.
  • Ergonomics — a non-widget QueryObserver that owns its subscription (so observe no longer leaks the GC question). useQueries is intentionally skipped as too React-flavored; a type-safe record combinator is the preferred path. (flutter_hooks useSwrlyQuery / useSwrlyMutation already ship in the swrly_hooks companion package.)
  • Persistence — a pluggable adapter interface (hive / shared_preferences / drift) for offline-first caching.
  • Ecosystem — a DevTools panel, and Riverpod / Bloc AsyncValue bridges.

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

2
likes
160
points
443
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Server-state cache for Flutter — dedupe, cache by query key, stale-while-revalidate. A TanStack Query for Flutter.

Repository (GitHub)
View/report issues

Topics

#state-management #cache #http #async #networking

License

MIT (license)

Dependencies

flutter

More

Packages that depend on swrly