flutter_swr
Flutter Hooks for data fetching — a Dart port of SWR, Vercel's React
data-fetching library, built on top of flutter_hooks.
It revalidates when the app resumes, on a fixed interval, and when the device comes back online, so widgets stay current on their own with no manual refresh logic.
Pass a key and a fetcher to useSwr. The hook manages the request, caches the response, and keeps
data fresh — you get data, error, and isLoading back to drive your UI.
Table of contents
- Why does this exist?
- Get started
- Deep dive
- Core concepts
useSwrSwrResponse<T>- Mutation
useSwrMutationuseSwrInfinite(pagination)- Configuration:
SwrProviderandSwrConfig - Conditional / dependent fetching
- Automatic revalidation
- Revalidate on reconnect
- Caching and deduplication
- Persisted caching: SqfliteSwrCache
- Error handling and retry
- A fuller example
- flutter_swr vs. React SWR
- Contributing
- License
Why does this exist?
Flutter has no equivalent to SWR or React Query. The idiomatic pattern today is a FutureBuilder
wired to manual setState, or reaching for Bloc / Riverpod's FutureProvider — each one either
re-fetches on every rebuild, makes you hand-roll caching, or drags in a whole state-management
architecture just to share one cached GET request across widgets.
flutter_swr is the small, focused piece that's missing: stale-while-revalidate
(RFC 5861) data fetching — show cached data immediately,
refetch quietly in the background — plus deduplication and mutation, without asking you to
restructure how the rest of your app manages state.
What it isn't:
- Not a state-management framework — it doesn't replace Bloc/Riverpod/Provider for app state.
- Not an HTTP client — the fetcher is just a plain
Future<T> Function()you write. - Not a middleware system — need logging or other cross-cutting behavior? Wrap the fetcher.
Get started
The smallest possible usage — a HookWidget, a key, and a fetcher:
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_swr/flutter_swr.dart';
class ProfileScreen extends HookWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
final (response, mutate) = useSwr<String>(
'user/profile',
fetcher: () => fetchProfileName(),
);
return response.when(
loading: () => const CircularProgressIndicator(),
error: (error, stackTrace) => Text('Failed: $error'),
data: (name) => Text('Hello, $name'),
);
}
}
That's it — no provider setup required. The first call fetches and caches under the key
'user/profile'; any other widget that calls useSwr('user/profile') anywhere in the tree
instantly gets the cached value and shares the same in-flight request.
Deep dive
Core concepts
| Concept | In flutter_swr |
|---|---|
| Stale-while-revalidate | The hook returns whatever's cached for a key on the first frame, then kicks off a background fetch and rebuilds subscribers when it resolves. |
| Cache key | Any hashableObject — typically a String ('/api/user/$id'), but a List or a Dart 3 Record works too. |
| Global cache | A singletonSwrCache shared by every useSwr call by default. Scope a different instance to a subtree with SwrProvider. |
| Deduplication | ConcurrentuseSwr calls for the same key share a single in-flight fetch rather than issuing parallel requests. |
useSwr
(SwrResponse<T>, SwrMutate<T>) useSwr<T>(
Object? key, {
Future<T> Function()? fetcher,
SwrConfig? config,
})
key— the cache key. Passnullto skip fetching entirely (see Conditional fetching).fetcher— overrides the ambient config's fetcher for this call. If you set afetcheron aSwrProvider(see below), you can omit this per call.config— a per-callSwrConfigmerged on top of the nearestSwrProvider's config; any field you don't set falls through to the ambient value. This is wherededupingInterval,refreshInterval,retry,revalidateOnFocus,revalidateOnReconnect, andonError/onSuccessare set per call.
It returns a record: a SwrResponse<T> snapshot, and a SwrMutate<T> function bound to this call's key.
SwrResponse<T>
class SwrResponse<T> {
final T? data;
final Object? error;
final StackTrace? stackTrace;
final bool isLoading; // true only until the *first ever* fetch for this key resolves
final bool isValidating; // true whenever a fetch (initial or background) is in flight
}
Read it directly, or pattern-match it the way you'd match a Riverpod AsyncValue:
// when: every branch required, exhaustive
response.when(
loading: () => const CircularProgressIndicator(),
error: (error, stackTrace) => Text('Error: $error'),
data: (products) => ProductGrid(products),
);
// maybeWhen: handle what you care about, fall back for the rest
response.maybeWhen(
data: (products) => ProductGrid(products),
orElse: () => const SizedBox.shrink(),
);
// map / maybeMap: same idea, but callbacks receive the full SwrResponse
// (useful when you also want isValidating alongside data/error/loading)
response.map(
data: (r) => ProductGrid(r.data!, isRefreshing: r.isValidating),
error: (r) => ErrorView(r.error!),
loading: (r) => const CircularProgressIndicator(),
);
isLoading and isValidating are independent: isLoading is only true before the very first
fetch for a key resolves, while isValidating is true for any fetch in flight — including a
silent background refresh of data you're already showing. Use isValidating to show a subtle
"refreshing" indicator without hiding the data you already have.
Mutation
Every useSwr call returns a bound mutate alongside its response:
final (response, mutate) = useSwr<List<Product>>('/products');
// Revalidate: refetch and update the cache
await mutate();
// Write directly, then still revalidate in the background
await mutate(data: updatedList);
// Write via an updater function
await mutate(updater: (current) => [...?current, newProduct]);
// Cascade: also revalidate other keys that currently have active subscribers
await mutate(invalidate: ['/products/summary']);
For mutating a key from outside the widget that owns it (e.g. after a delete elsewhere in the
app), use the top-level mutate, which operates on the shared default cache:
import 'package:flutter_swr/flutter_swr.dart' as swr;
await deleteProduct(productId);
await swr.mutate<List<Product>>(
'/products',
data: currentList.where((p) => p.id != productId).toList(),
revalidate: false, // we already know the correct end state; skip the refetch
);
useSwrMutation
For writes (POST/PUT/DELETE) that should run only when the user asks, use useSwrMutation. It
doesn't run on mount: it returns its own state plus a trigger that runs the request.
final (state, trigger) = useSwrMutation<Todo, String>(
(title) => api.createTodo(title),
options: SwrMutationOptions(
// /todos caches List<Todo>, not Todo, so refresh it by key instead.
onSuccess: (_, _, _) => mutate<List<Todo>>('/todos'),
),
);
FilledButton(
onPressed: state.isMutating ? null : () => trigger('Buy milk'),
child: const Text('Add'),
);
state is a SwrMutationState<T> (data, error, stackTrace, isMutating). It belongs to this
hook alone. Two useSwrMutations on the same key don't share it, and useSwr readers never see
it. trigger throws on failure by default, so wrap it in try/catch, or pass
throwOnError: false to get null back instead.
Bound to a key: optimistic update and rollback. Pass key: to act on that cache entry. T must
then be the key's cached type.
final (renameState, trigger) = useSwrMutation<User, String>(
(name) => api.renameUser(name),
key: '/api/user',
options: SwrMutationOptions(
optimisticData: (current, name) => current?.copyWith(name: name), // shown immediately
populateCache: true, // cache the server's response
// rollbackOnError: true (default) restores the previous value if the request throws
),
);
While a bound mutation runs, any useSwr fetch for the key that was already in flight has its
result thrown away, so stale pre-mutation data never overwrites what the mutation wrote. Once the
mutation settles, successfully or not, the key's mounted useSwr readers are revalidated. Pass
revalidate: false to skip that.
| Option | Default | Purpose |
|---|---|---|
optimisticData |
none | (current, arg) => next, written before the request. Returning null skips it. |
populateCache |
false, or true if populateCacheWith is set |
Write the result to the cache. |
populateCacheWith |
none | (result, current) => toCache, for when the response isn't the cached shape. |
rollbackOnError |
true |
Restore the pre-mutation value on failure. |
revalidate |
true |
Revalidate the key's mounted readers afterwards. It isn't awaited. |
throwOnError |
true |
Rethrow fromtrigger. When false, trigger completes with null. |
onSuccess / onError |
none | Called for the latest trigger only, with(data or error, key, arg). |
Options are set once, on the hook. trigger takes only the argument. To act on a single call's
outcome, await it: final saved = await trigger(arg);, with try/catch for errors.
Some more details:
- No argument: use
Arg = voidand calltrigger(). IfArgis non-nullable, callingtrigger()withoutdatathrows anArgumentError. - Latest trigger wins: if you trigger again before the first call finishes, only the newest call
updates
stateand fires the callbacks. Every call's ownFuturestill completes. trigger.reset()putsstateback to idle and ignores the result of any call still in flight.- No retry, no dedup: mutations aren't idempotent, so every
triggercalls the fetcher exactly once. - Key-only options:
optimisticData,populateCacheandpopulateCacheWithneed akey. If you set them without one, debug builds throw anAssertionErrorand release builds ignore them.
useSwrInfinite (pagination)
For "load more" lists, useSwrInfinite loads a list page by page. getKey returns each page's key
from its index and the previous page, or null when there are no more pages:
final (pages, infinite) = useSwrInfinite<UsersPage>(
(index, previous) => previous != null && previous.isLast
? null // no more pages
: '/api/users?page=${index + 1}',
fetcher: (key) => api.fetchUsersPage(key as String),
);
final users = [for (final page in pages.data ?? const <UsersPage>[]) ...page.users];
if (!infinite.isReachingEnd)
TextButton(
onPressed: infinite.isLoadingMore ? null : () => infinite.setSize(infinite.size + 1),
child: Text(infinite.isLoadingMore ? 'Loading…' : 'Load more'),
);
-
pagesis an ordinarySwrResponse<List<T>>(one element per page), sowhen/mapwork as usual. It only changes once a whole load finishes, so it never shows a half-loaded list. -
infinite.sizeis how many pages are requested.setSize(n)loads the missing pages and completes with the new response. A failure is in that response'serror; it isn't thrown. -
infinite.isLoadingMoreandinfinite.isReachingEndread the latest state, so they're safe to check after anawait, e.g. in a refresher's load callback:onLoading: () async { final result = await infinite.setSize(infinite.size + 1); if (result.error != null) return refresh.loadFailed(); infinite.isReachingEnd ? refresh.loadNoData() : refresh.loadComplete(); }, -
infinite.mutate(...)works likeuseSwr's boundmutate, on the whole list, and then refetches every page. Use it for pull-to-refresh. -
The list is cached under
swrInfiniteKey(getKey), and each page under its own key too. Usemutate(swrInfiniteKey(getKey)!)to revalidate the list from elsewhere, or pass it asuseSwrMutation'skey(withT=List<Page>) for optimistic list updates.mutate(pageKey)updates only that page's entry, not the list. -
Revalidations (mount, app resume, reconnect, polling) refetch only the first page and reuse cached pages for the rest.
SwrInfiniteOptionschanges that:initialSize(1),revalidateFirstPage(true),revalidateAll(false),persistSize(false; keep the page count when the first page's key changes) andparallel(false; fetch all pages at once, withpreviousPageDataalwaysnull). -
The number of loaded pages is remembered per list, so navigating back to a list shows the same pages from cache.
The example app's Users screen (people icon on the Store screen) shows it with pull_to_refresh
against reqres.in.
Ready-made widgets: SwrInfiniteListView and SwrInfiniteGridView
The example app wraps useSwrInfinite and pull_to_refresh's SmartRefresher in two reusable
widgets: pull down refetches every page, pull up loads the next one, and the end of the list is
detected for you. They live in the example app, not the package, because pull_to_refresh isn't a
package dependency. To use them, copy
swr_infinite_view.dart into your app.
SwrInfiniteListView<UsersPage, User>(
getKey: UsersApi.pageKey,
fetcher: usersApi.fetchPage,
itemsOf: (page) => page.users, // flattens each page into items
itemBuilder: (context, user, index) => UserTile(user: user),
)
SwrInfiniteGridView<UsersPage, User>(
getKey: UsersApi.pageKey,
fetcher: usersApi.fetchPage,
itemsOf: (page) => page.users,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
itemBuilder: (context, user, index) => UserCard(user: user),
)
Pis the page type the fetcher returns;Iis the item type drawn.fetcher,options(SwrInfiniteOptions) andconfiggo straight touseSwrInfinite.- Optional builders, with defaults:
loadingBuilder(spinner),errorBuilder(context, error, retry)(a retry panel, shown when the first load fails),emptyBuilder(shown inside the refresher, so pull-down still works). skipError(defaulttrue) keeps the items on screen when a later refresh or load-more fails. Set it tofalseto showerrorBuilderinstead.- Every
SmartRefresherproperty is passed through with its default (controller,header,footer,physics, …), except thatenablePullUpdefaults totrue.onRefresh/onLoadingrun after the built-in refresh/load-more, not instead of it. SwrInfiniteListViewaddsseparatorBuilderandpadding;SwrInfiniteGridViewaddsgridDelegateandpadding. For another layout, extendSwrInfiniteViewand implementbuildScrollable(context, items).
Configuration: SwrProvider and SwrConfig
Scope defaults to a subtree with SwrProvider, so individual useSwr calls don't need to repeat
a fetcher or intervals:
SwrProvider(
config: SwrConfig(
fetcher: (key) => apiClient.get(key as String),
dedupingInterval: const Duration(seconds: 30),
revalidateOnFocus: true,
),
child: const MaterialApp(home: HomeScreen()),
);
Nested SwrProviders merge: a child's non-null fields override its ancestor's, and unset fields
fall through — same as a per-call config: merges over the nearest provider.
| Field | Type | Default | Purpose |
|---|---|---|---|
fetcher |
Future<dynamic> Function(Object key)? |
none | Default fetcher, resolved by key, used when auseSwr call omits its own fetcher. |
dedupingInterval |
Duration? |
2s | How long a cached result is considered fresh enough to skip an automatic revalidation. |
refreshInterval |
Duration? |
none (no polling) | Poll this key on a fixed interval while it has an active subscriber. |
retry |
SwrRetryPolicy? |
5 attempts, exponential backoff up to 30s | Retry behavior on fetcher failure. |
revalidateOnFocus |
bool? |
true |
Whether resuming the app from the background revalidates this key. |
connectivity |
SwrConnectivity? |
none (reconnect revalidation off) | Your source of online/offline status, used byrevalidateOnReconnect (see below). |
revalidateOnReconnect |
bool? |
true |
Whether coming back online revalidates this key. Needs aconnectivity. |
onError / onSuccess |
callbacks | none | Side-effect hooks fired on fetch failure/success. |
cache |
SwrCache? |
sharedInMemoryCache |
Swap in a custom cache implementation (see below). |
Conditional / dependent fetching
Pass null as the key to skip fetching — useful when a request depends on data that isn't ready yet:
final (userResponse, _) = useSwr<User>('/user');
final (postsResponse, _) = useSwr<List<Post>>(
userResponse.data == null ? null : '/user/${userResponse.data!.id}/posts',
);
Flipping from null to a real key starts fetching immediately on that rebuild. Flipping back to
null unsubscribes (cancels any polling) but leaves the cached data in place for next time.
Automatic revalidation
Three triggers keep mounted keys fresh:
- On app resume — when the app returns to the foreground, every currently-mounted key with
revalidateOnFocus: true(the default) revalidates, throttled to once per 5 seconds per key so rapid background/foreground cycling doesn't cause a revalidation storm. - Polling — set
refreshInterval(globally viaSwrConfig, or per call viauseSwr'sconfig:) to refetch on a fixed timer. Polling is ref-counted per key (only runs while at least one widget is subscribed) and automatically pauses while the app is backgrounded, resuming with an immediate revalidation when it comes back. - On reconnect — when the device comes back online, every mounted key with
revalidateOnReconnect: true(the default) revalidates. Dart has no built-in "online" event, so this needs one line of setup: pass your connectivity source asSwrConfig.connectivity(see below).
Revalidate on reconnect
When the device comes back online, every mounted key with revalidateOnReconnect: true (the
default) revalidates, like React SWR's revalidateOnReconnect. Dart has no built-in "online" event,
and flutter_swr doesn't depend on any connectivity package. Instead, you tell it where online status
comes from by implementing SwrConnectivity, which is a single Stream<bool> where true means online:
// With observe_internet_connectivity (checks that the internet is actually reachable):
class InternetConnectivityAdapter extends SwrConnectivity {
@override
Stream<bool> get onConnectivityChanged =>
InternetConnectivity().observeInternetConnection;
}
// Or with connectivity_plus (reports the network interface only):
class ConnectivityPlusAdapter extends SwrConnectivity {
@override
Stream<bool> get onConnectivityChanged => Connectivity()
.onConnectivityChanged
.map((results) => !results.contains(ConnectivityResult.none));
}
// Or wrap any existing stream:
final connectivity = SwrConnectivity.fromStream(myOnlineStream);
Then pass it to a provider. Nested providers inherit it:
final connectivity = InternetConnectivityAdapter(); // create once, outside build
SwrProvider(
config: SwrConfig(connectivity: connectivity),
child: const MaterialApp(home: HomeScreen()),
);
- Only an offline→online transition revalidates. The stream's first value is taken as the starting status, and repeated values are ignored.
- flutter_swr listens to each
SwrConnectivityinstance once and never cancels, so create it once (for example, in a static field) rather than insidebuild. - To opt a key out, pass
config: const SwrConfig(revalidateOnReconnect: false)touseSwr. - Polling keeps running while offline. flutter_swr has no equivalent of React's
refreshWhenOfflineyet.
The example app wires this up with observe_internet_connectivity in internet_connectivity_adapter.dart.
Caching and deduplication
By default, every useSwr call in your app shares one in-memory cache (InMemoryCache). Keys are
normalized so that equal Strings, Dart 3 Records, and deeply-equal Lists all map to the same
cache entry. Concurrent calls for the same key within the dedup window share a single in-flight
fetch — issuing the request once no matter how many widgets ask for it at the same moment.
You can supply your own cache (e.g. a persisted one) by implementing SwrCache and passing it via
SwrConfig(cache: myCache):
abstract class SwrCache {
CacheEntry<T>? get<T>(Object key);
void set<T>(Object key, CacheEntry<T> entry);
void delete(Object key);
Iterable<Object> keys();
Stream<CacheEntry<T>?> watch<T>(Object key);
bool hasWatchers(Object key);
}
Persisted caching: SqfliteSwrCache
The default InMemoryCache doesn't survive an app restart. The example/ app shows
how to swap in a persisted cache instead, backed by sqflite,
so data fetched before the app was closed is available instantly on the next cold start:

final cache = await SqfliteSwrCache.open(
fromJson: {...swrModel<Product>(Product.fromJson)},
);
runApp(StoreApp(cache: cache));
// inside StoreApp:
SwrProvider(
config: SwrConfig(
fetcher: storeFetcher.fetch,
cache: cache, // swap the default InMemoryCache for the persisted one
),
child: MaterialApp(home: const ProductListScreen()),
);
swrModel<T>(fromJson) is a small helper that registers a model's fromJson for both T and
List<T> in one call — needed because sqflite has no synchronous read API, so cached rows are
decoded lazily by type the first time they're read. The model itself just needs a toJson() and a
fromJson() (see Product in example/lib/src/models/product.dart).
Copy the full implementation from either of these to use in your own app — both are
example-app-local, validating the SwrCache interface against a real persistence backend, not
part of the published package's public API:
- SqfliteSwrCache — the sqflite-backed cache shown above.
- SharedPreferencesSwrCache — a lighter-weight option for smaller datasets.
Error handling and retry
Fetcher failures are retried automatically with exponential backoff (5 attempts by default, capped
at 30s between attempts) before the error surfaces in SwrResponse.error. Customize or disable
this via SwrRetryPolicy:
SwrConfig(
retry: const SwrRetryPolicy(maxAttempts: 3),
);
A fuller example
The example/ app is a small product catalog against the public
Fake Store API, showing the patterns above in a real widget tree: a
root SwrProvider with a shared fetcher, list/detail screens sharing a cache key, pull-to-refresh
via bound mutate, and an optimistic delete via the global mutate. Run it with:
cd example
flutter run
flutter_swr vs. React SWR
| React SWR | flutter_swr |
|---|---|
useSWR(key, fetcher) |
useSwr<T>(key, fetcher: fetcher) |
<SWRConfig value={...}> |
SwrProvider(config: SwrConfig(...)) |
mutate from useSWRConfig() |
top-levelmutate<T>(key, ...) |
useSWRMutation(key, fetcher, options) |
useSwrMutation<T, Arg>(fetcher, key:, options:) — see below |
useSWRInfinite(getKey, fetcher, options) |
useSwrInfinite<T>(getKey, fetcher:, options:) — see below |
unstable_serialize(getKey) |
swrInfiniteKey(getKey) |
revalidateOnFocus (tab refocus) |
revalidateOnFocus (app resume) |
revalidateOnReconnect |
revalidateOnReconnect + your SwrConfig.connectivity adapter |
data/error/isLoading/isValidating |
same fields onSwrResponse<T>, plus when/map pattern matching |
useSwrMutation differs from useSWRMutation in these ways:
- It returns a
(state, trigger)record.resetis a method on the trigger:trigger.reset(). trigger(arg)takes no per-call options. Set them on the hook, and awaittriggerto handle one call's result.- The fetcher takes only the argument,
(arg) => ..., not(key, {arg}). keyis optional. Leaving it out means "don't touch the cache", not "disabled".optimisticDatais always a function, and it also receives the trigger argument.populateCacheis split into aboolfield and apopulateCacheWithfunction.- Callbacks receive
arg, andonErroralso receives theStackTrace. triggerresolves without waiting for the follow-up revalidation. A boundmutatedoes wait for its revalidation.
useSwrInfinite differs from useSWRInfinite in these ways:
- It returns a
(response, infinite)record.size,setSizeandmutatelive oninfinite. setSizetakes anint, not an updater, and must be at least 1. It completes with the new response.- It adds
isLoadingMoreandisReachingEnd, which React leaves callers to derive. mutatehas norevalidate: falseoption (neither doesuseSwr's boundmutate).
Contributing
See CONTRIBUTING.md for project layout, local setup, and test conventions.
License
MIT — see LICENSE.
Libraries
- flutter_swr
- Flutter Hooks for Data Fetching.