SWR strategy implementation guide for fast UI responsiveness when fetching data in projects.
Overview#
The SWR strategy handles two scenarios:
| Scenario | Behavior |
|---|---|
| No cache | Show loading state -> Server request -> Display data |
| Cache exists | Display cached data immediately -> Background server request -> Refresh data |
Role by Architecture Layer#
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Page (Widget) โ
โ - BlocBuilder๋ก ์ํ ๋ ๋๋ง โ
โ - ๋ก๋ฉ/์๋ฌ/๋ฐ์ดํฐ ์ํ ์ฒ๋ฆฌ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ add(Event)
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ BLoC โ
โ - Stream ๊ตฌ๋
(emit.forEach) โ
โ - ์บ์ โ emit โ ์๋ฒ โ emit (์ง์ฐ ์๋ต ์ฒ๋ฆฌ) โ
โ - isClosed ์ฒดํฌ ํ์ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ call(params)
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ UseCase (Stream ๊ธฐ๋ฐ) โ
โ - Stream < Either < Failure, T > > ๋ฐํ โ
โ - async* + yield๋ก ์์ฐจ ๋ฐ์ดํฐ ๋ฐฉ์ถ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ getWithCache(params)
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Repository โ
โ - ์บ์ ์กฐํ โ yield (์บ์ ๋ฐ์ดํฐ) โ
โ - ์๋ฒ ์์ฒญ โ yield (์๋ฒ ๋ฐ์ดํฐ) โ
โ - ์บ์ ๊ฐฑ์ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
1. Repository Pattern (Cache + Server Sequential Return)#
Stream-based Repository Method#
// repository_interface.dart
abstract class IBookRepository {
/// SWR pattern: cache -> server sequential return
Stream<Either<Failure, List<Book>>> getBooksWithCache({
required int categoryId,
int? limit,
});
/// Before Future ํจํด (๋จ์ ์๋ฒ ํธ์ถ)
Future<Either<Failure, List<Book>>> getBooks({
required int categoryId,
int? limit,
});
}
Repository Implementation#
// book_repository.dart
class BookRepository implements IBookRepository {
const BookRepository(this._client, this._cacheService);
final ApiClient _client;
final BookCacheService _cacheService;
@override
Stream<Either<Failure, List<Book>>> getBooksWithCache({
required int categoryId,
int? limit,
}) async* {
final cacheKey = 'books_$categoryId';
// 1. Check and emit cached data (if available)
final cachedBooks = _cacheService.get(cacheKey);
if (cachedBooks != null && cachedBooks.isNotEmpty) {
yield right(cachedBooks);
}
// 2. Server request
try {
final serverBooks = await _client.book.getBooks(
categoryId: categoryId,
limit: limit,
);
// 3. Cache refresh
_cacheService.set(cacheKey, serverBooks);
// 4. Emit server data
yield right(serverBooks);
} on Exception catch (error, stackTrace) {
Log.e('๋์ ๋ชฉ๋ก ์กฐํ ์คํจ', error: error, stackTrace: stackTrace);
// Ignore error if cache existed (cached data already displayed)
if (cachedBooks == null || cachedBooks.isEmpty) {
yield left(BookLoadFailure(message: error.toString()));
}
}
}
}
2. UseCase Pattern (Stream-based)#
StreamUseCase Abstract Class#
// stream_usecase.dart
/// Stream ๊ธฐ๋ฐ UseCase ์ธํฐํ์ด์ค (SWR ํจํด์ฉ)
///
/// ์บ์ ๋ฐ์ดํฐ์ ์๋ฒ ๋ฐ์ดํฐ๋ฅผ ์์ฐจ์ ์ผ๋ก ๋ฐฉ์ถํฉ๋๋ค.
abstract class StreamUseCase<T, Params, Repo> {
/// Repository instance
Repo get repo;
/// Execute UseCase (returns Stream)
///
/// On success, sequentially emits data of type [T],
/// on failure, emits error of type [Failure].
Stream<Either<Failure, T>> call(Params param);
}
/// Stream UseCase requiring authentication
abstract class AuthRequiredStreamUseCase<T, Params, Repo>
extends StreamUseCase<T, Params, Repo>
with GlobalAuthMixin {
String get featureName;
Stream<Either<Failure, T>> executeBusinessLogic(Params param);
@override
Stream<Either<Failure, T>> call(Params param) async* {
// Auth check
final isAuthenticated = await checkAuthentication();
if (!isAuthenticated) {
yield left(AuthenticationFailure(message: '$featureName: ์ธ์ฆ์ด ํ์ํฉ๋๋ค.'));
return;
}
// Execute business logic (Stream)
await for (final result in executeBusinessLogic(param)) {
yield result;
}
}
}
UseCase Implementation Example#
// get_books_with_cache_usecase.dart
class GetBooksWithCacheUseCase
implements StreamUseCase<List<Book>, GetBooksParams, IBookRepository> {
const GetBooksWithCacheUseCase();
@override
IBookRepository get repo => getIt<IBookRepository>();
@override
Stream<Either<Failure, List<Book>>> call(GetBooksParams params) {
return repo.getBooksWithCache(
categoryId: params.categoryId,
limit: params.limit,
);
}
}
3. BLoC Pattern (Stream Subscription)#
Using emit.forEach (Recommended)#
// book_bloc.dart
class BookBloc extends Bloc<BookEvent, BookState> {
BookBloc(this._getBooksWithCacheUseCase) : super(const BookState()) {
on<_LoadBooks>(_onLoadBooks);
}
final GetBooksWithCacheUseCase _getBooksWithCacheUseCase;
Future<void> _onLoadBooks(
_LoadBooks event,
Emitter<BookState> emit,
) async {
// Show loading state only when no cache
if (state.books.isEmpty) {
emit(state.copyWith(status: const BookStatusLoading()));
}
// โ
Subscribe to Stream via emit.forEach
await emit.forEach<Either<Failure, List<Book>>>(
_getBooksWithCacheUseCase(
GetBooksParams(categoryId: event.categoryId),
),
onData: (result) {
return result.fold(
(failure) => state.copyWith(
status: BookStatusError(failure.toString()),
),
(books) => state.copyWith(
status: const BookStatusLoaded(),
books: books,
lastUpdated: DateTime.now(),
),
);
},
onError: (error, stackTrace) {
Log.e('๋์ ๋ชฉ๋ก ๋ก๋ ์คํจ', error: error, stackTrace: stackTrace);
return state.copyWith(
status: BookStatusError(error.toString()),
);
},
);
}
}
Manual Stream Subscription (Alternative)#
Future<void> _onLoadBooks(
_LoadBooks event,
Emitter<BookState> emit,
) async {
if (state.books.isEmpty) {
emit(state.copyWith(status: const BookStatusLoading()));
}
// โ
Traverse Stream with await for
await for (final result in _getBooksWithCacheUseCase(
GetBooksParams(categoryId: event.categoryId),
)) {
// โ ๏ธ Required: isClosed check
if (isClosed) return;
emit(result.fold(
(failure) => state.copyWith(
status: BookStatusError(failure.toString()),
),
(books) => state.copyWith(
status: const BookStatusLoaded(),
books: books,
lastUpdated: DateTime.now(),
),
));
}
}
4. State Design (SWR Compatible)#
State Class#
// book_state.dart
@freezed
class BookState with _$BookState {
const factory BookState({
@Default(BookStatusInitial()) BookStatus status,
@Default([]) List<Book> books,
DateTime? lastUpdated, // ์บ์/์๋ฒ ๊ตฌ๋ถ์ฉ
}) = _BookState;
}
@freezed
sealed class BookStatus with _$BookStatus {
const factory BookStatus.initial() = BookStatusInitial;
const factory BookStatus.loading() = BookStatusLoading;
const factory BookStatus.loaded() = BookStatusLoaded;
const factory BookStatus.error(String message) = BookStatusError;
const factory BookStatus.refreshing() = BookStatusRefreshing; // โ
๋ฐฑ๊ทธ๋ผ์ด๋ ๊ฐฑ์ in progress
}
Cache/Server Data Distinction State#
// ๋ ์ ๊ตํ ์ํ ๊ด๋ฆฌ
@freezed
sealed class BookStatus with _$BookStatus {
const factory BookStatus.initial() = BookStatusInitial;
const factory BookStatus.loading() = BookStatusLoading;
const factory BookStatus.cachedData() = BookStatusCachedData; // โ
์บ์ ๋ฐ์ดํฐ ํ์ in progress
const factory BookStatus.refreshing() = BookStatusRefreshing; // โ
๋ฐฑ๊ทธ๋ผ์ด๋ ๊ฐฑ์ in progress
const factory BookStatus.freshData() = BookStatusFreshData; // โ
์๋ฒ ๋ฐ์ดํฐ๋ก ๊ฐฑ์ ๋จ
const factory BookStatus.error(String message) = BookStatusError;
}
5. UI Pattern (Page/Widget)#
Using BlocBuilder#
// book_list_page.dart
class BookListPage extends StatelessWidget {
const BookListPage({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<BookBloc, BookState>(
buildWhen: (prev, curr) =>
prev.status != curr.status ||
prev.books.length != curr.books.length || // โ ๏ธ ๋ฆฌ์คํธ๋ length๋ก ๋น๊ต
prev.lastUpdated != curr.lastUpdated,
builder: (context, state) {
return switch (state.status) {
BookStatusInitial() ||
BookStatusLoading() when state.books.isEmpty =>
const BookListSkeleton(), // ๋ก๋ฉ ์ค์ผ๋ ํค
BookStatusCachedData() ||
BookStatusRefreshing() =>
BookListView(
books: state.books,
isRefreshing: state.status is BookStatusRefreshing,
),
BookStatusLoaded() ||
BookStatusFreshData() =>
BookListView(books: state.books),
BookStatusError(:final message) when state.books.isEmpty =>
ErrorView(message: message),
BookStatusError() =>
BookListView(
books: state.books,
errorMessage: (state.status as BookStatusError).message,
),
_ => BookListView(books: state.books),
};
},
);
}
}
Background Refresh Indicator#
class BookListView extends StatelessWidget {
const BookListView({
required this.books,
this.isRefreshing = false,
this.errorMessage,
super.key,
});
final List<Book> books;
final bool isRefreshing;
final String? errorMessage;
@override
Widget build(BuildContext context) {
return Column(
children: [
// ๋ฐฑ๊ทธ๋ผ์ด๋ ๊ฐฑ์ in progress ํ์
if (isRefreshing)
const LinearProgressIndicator(),
// ์๋ฌ ๋ฉ์์ง (๋ฐ์ดํฐ๋ ์์ง๋ง ๊ฐฑ์ ์คํจ)
if (errorMessage != null)
ErrorBanner(message: errorMessage!),
// ๋ฐ์ดํฐ ๋ชฉ๋ก
Expanded(
child: ListView.builder(
itemCount: books.length,
itemBuilder: (context, index) => BookTile(book: books[index]),
),
),
],
);
}
}
6. Cache Service Pattern#
Memory Cache Service#
// book_cache_service.dart
@singleton
class BookCacheService {
final _cache = <String, List<Book>>{};
final _timestamps = <String, DateTime>{};
/// Cache validity duration (5 minutes)
static const _cacheDuration = Duration(minutes: 5);
List<Book>? get(String key) {
final timestamp = _timestamps[key];
if (timestamp == null) return null;
// Cache expiration check
if (DateTime.now().difference(timestamp) > _cacheDuration) {
_cache.remove(key);
_timestamps.remove(key);
return null;
}
return _cache[key];
}
void set(String key, List<Book> books) {
_cache[key] = books;
_timestamps[key] = DateTime.now();
}
void invalidate(String key) {
_cache.remove(key);
_timestamps.remove(key);
}
void invalidateAll() {
_cache.clear();
_timestamps.clear();
}
}
7. Migration Guide#
Converting from Future-based to Stream-based#
// Before: Future ๊ธฐ๋ฐ
class GetBooksUseCase implements UseCase<List<Book>, GetBooksParams, IBookRepository> {
@override
Future<Either<Failure, List<Book>>> call(GetBooksParams params) async {
return repo.getBooks(categoryId: params.categoryId);
}
}
// After: Stream ๊ธฐ๋ฐ (SWR)
class GetBooksWithCacheUseCase
implements StreamUseCase<List<Book>, GetBooksParams, IBookRepository> {
@override
Stream<Either<Failure, List<Book>>> call(GetBooksParams params) {
return repo.getBooksWithCache(categoryId: params.categoryId);
}
}
BLoC Migration#
// Before: Future ๊ธฐ๋ฐ
Future<void> _onLoadBooks(_LoadBooks event, Emitter<BookState> emit) async {
emit(state.copyWith(status: const BookStatusLoading()));
final result = await _getBooksUseCase(params);
if (isClosed) return;
emit(result.fold(
(failure) => state.copyWith(status: BookStatusError(failure.toString())),
(books) => state.copyWith(status: const BookStatusLoaded(), books: books),
));
}
// After: Stream ๊ธฐ๋ฐ (SWR)
Future<void> _onLoadBooks(_LoadBooks event, Emitter<BookState> emit) async {
if (state.books.isEmpty) {
emit(state.copyWith(status: const BookStatusLoading()));
}
await emit.forEach<Either<Failure, List<Book>>>(
_getBooksWithCacheUseCase(params),
onData: (result) => result.fold(
(failure) => state.copyWith(status: BookStatusError(failure.toString())),
(books) => state.copyWith(
status: const BookStatusLoaded(),
books: books,
lastUpdated: DateTime.now(),
),
),
);
}
8. Anti-patterns (When Consuming SWR Stream in BLoC)#
โ .last - Complete loss of SWR benefits#
// โ WRONG: .last only waits for Stream's last value (server data)
// Ignores immediately emitted cache data -> shows loading spinner until server response
final result = await _useCases.getBooks(params).last;
Problem: The SWR "show stale data first" pattern is completely invalidated. Actual case: 7 methods in
my_library_bloc lost cache benefits due to .last usage (fixed in PR #4584).
โ .first - Ignoring server refresh#
// โ WRONG: .first only collects Stream's first value (cache)
// Ignores latest data from server -> only shows stale cache data
final result = await _useCases.getUserInfo(const NoParams()).first;
Problem: The "background refresh" part of SWR is lost. Actual case: 10 calls in my_page_bloc
using .first (fixed in PR #4585).
Exception: In Future.wait parallel loading, the pattern of quickly showing cache with
.first while adding emit.forEach background refresh via separate events is allowed.
โ Direct .listen usage - StreamSubscription management burden#
// โ WRONG: Direct listen inside BLoC -> requires subscription management
final subscription = _useCases.getBooks(params).listen(
(result) => add(BooksUpdated(result)),
);
Problem: Missing subscription cancellation on close() causes memory leaks.
emit.forEach automatically manages subscriptions.
Exception: .listen is appropriate for non-SWR WebSocket/SSE stream subscriptions like real-time chat.
โ
Correct pattern - emit.forEach + restartable()#
// โ
CORRECT: Subscribe to SWR Stream via emit.forEach
on<_LoadBooks>(_onLoadBooks, transformer: restartable());
Future<void> _onLoadBooks(_LoadBooks event, Emitter<State> emit) async {
if (state.books.isEmpty) {
emit(state.copyWith(status: const Loading()));
}
await emit.forEach<Either<Failure, List<Book>>>(
_useCases.getBooks(params),
onData: (result) => result.fold(
(failure) => state.copyWith(status: Error(failure)),
(books) => state.copyWith(status: const Loaded(), books: books),
),
onError: (_, _) => state.copyWith(status: const Error('๋ก๋ ์คํจ')),
);
}
Exception for Pagination "Load More"#
.last is appropriate for Load More (append):
- Since SWR cache key doesn't include offset, cached data is previous page data
- Subscribing with emit.forEach would duplicate-append cache (previous page) + server (new page) data
// โ
Keep .last for Load More
final result = await _useCases.getBooks(
PaginationParams(limit: 20, offset: currentOffset),
).last;
// Append after deduplication
final uniqueNew = newBooks.where((b) => !existingIds.contains(b.id)).toList();
emit(state.copyWith(books: [...state.books, ...uniqueNew]));
9. Checklist#
SWR Implementation Checklist#
- Use
async*+yieldpattern in Repository - UseCase returns
Stream<Either<Failure, T>> -
Use
emit.forEachin BLoC (.last/.firstprohibited) -
Apply
restartable()transformer to events usingemit.forEach - Skip loading state when cache data exists
- Specify
onErrorcallback - Allow
.lastexception for pagination Load More - Add
lastUpdatedor data source distinction field to State
Related Documents#
- dcm-bloc.md - BLoC pattern rules
- CLAUDE.md - Complete project guide
- auth_required_usecase.dart - Existing UseCase Pattern