.claude/skills/asyncredux-async-actions/SKILL.md
Creates AsyncRedux (Flutter) asynchronous actions for API calls, database operations, and other async work.
npx skillsauth add marcglasberg/async_redux asyncredux-async-actionsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
An action becomes asynchronous when its reduce() method returns Future<AppState?>
instead of AppState?. Use this for database access, API calls, file operations, or any
work requiring await.
class FetchUser extends ReduxAction<AppState> {
@override
Future<AppState?> reduce() async {
final user = await api.fetchUser();
return state.copy(user: user);
}
}
Unlike traditional Redux requiring middleware, AsyncRedux makes it simple: return a
Future and it works.
If the action is async (returns a Future) and changes the state (returns a non-null
state), the framework requires that, all execution paths contain at least
one await. Never declare Future<AppState?> if you don't actually await something.
// Simple async with await
Future<AppState?> reduce() async {
final data = await fetchData();
return state.copy(data: data);
}
// Using microtask (minimum valid await)
Future<AppState?> reduce() async {
await microtask;
return state.copy(timestamp: DateTime.now());
}
// Conditional - both paths have await
Future<AppState?> reduce() async {
if (state.needsRefresh) {
return await fetchAndUpdate();
}
else return await validateCurrent();
}
// Always returns null
Future<AppState?> reduce() async {
if (state.needsRefresh) {
await fetchAndUpdate();
}
return null;
}
// WRONG: No await at all
Future<AppState?> reduce() async {
return state.copy(counter: state.counter + 1);
}
// WRONG: await only on some paths
Future<AppState?> reduce() async {
if (condition) {
return await fetchData();
}
return state; // No await on this path!
}
// WRONG: Calling async function without await
Future<AppState?> reduce() async {
someAsyncFunction(); // Not awaited
return state;
}
For complex reducers with multiple code paths, add assertUncompletedFuture() before the
final return. This catches violations at runtime during development:
class ComplexAction extends ReduxAction<AppState> {
@override
Future<AppState?> reduce() async {
if (state.cacheValid) {
// Complex logic that might accidentally skip await
return processCache();
}
final data = await fetchFromServer();
final processed = transform(data);
assertUncompletedFuture(); // Validates at least one await occurred
return state.copy(data: processed);
}
}
The state getter can change after every await because other actions may modify state
while yours is waiting:
class AsyncAction extends ReduxAction<AppState> {
@override
Future<AppState?> reduce() async {
print(state.counter); // e.g., 5
await someSlowOperation();
// state.counter might now be different (e.g., 10)
// if another action modified it during the await
print(state.counter);
return state.copy(counter: state.counter + 1);
}
}
Use initialState to access the state as it was when the action was dispatched (never
changes):
class SafeIncrement extends ReduxAction<AppState> {
@override
Future<AppState?> reduce() async {
final originalCounter = initialState.counter;
await validateWithServer();
// Check if state changed while we were waiting
if (state.counter != originalCounter) {
// State was modified by another action
return null; // Abort our change
}
return state.copy(counter: state.counter + 1);
}
}
Use dispatch() when you don't need to wait for completion:
context.dispatch(FetchUser());
// Returns immediately, action runs in background
Use dispatchAndWait() to await the action's completion:
await context.dispatchAndWait(FetchUser());
// Continues only after action finishes AND state changes
print('User loaded: ${context.state.user.name}');
// Fire all, don't wait
context.dispatchAll([FetchUser(), FetchSettings(), FetchNotifications()]);
// Fire all and wait for all to complete
await context.dispatchAndWaitAll([FetchUser(), FetchSettings()]);
Use isWaiting() to show spinners while async actions run:
Widget build(BuildContext context) {
if (context.isWaiting(FetchUser)) return CircularProgressIndicator();
else return Text('Hello, ${context.state.user.name}');
}
Throw UserException for user-facing errors:
class FetchUser extends ReduxAction<AppState> {
@override
Future<AppState?> reduce() async {
final response = await api.fetchUser();
if (response.statusCode == 404)
throw UserException('User not found.');
if (response.statusCode != 200)
throw UserException('Failed to load user. Please try again.');
return state.copy(user: response.data);
}
}
Check for failures in widgets:
Widget build(BuildContext context) {
if (context.isFailed(FetchUser)) {
return Text('Error: ${context.exceptionFor(FetchUser)?.message}');
}
// ...
}
// Async action with proper error handling
class LoadProducts extends ReduxAction<AppState> {
@override
Future<AppState?> reduce() async {
try {
final products = await api.fetchProducts();
return state.copy(products: products, productsLoaded: true);
} catch (e) {
throw UserException('Could not load products. Check your connection.');
}
}
}
// Widget showing all three states
Widget build(BuildContext context) {
// Loading state
if (context.isWaiting(LoadProducts)) {
return Center(child: CircularProgressIndicator());
}
// Error state
if (context.isFailed(LoadProducts)) {
return Center(
child: Column(
children: [
Text(context.exceptionFor(LoadProducts)?.message ?? 'Error'),
ElevatedButton(
onPressed: () => context.dispatch(LoadProducts()),
child: Text('Retry'),
),
],
),
);
}
// Success state
return ListView.builder(
itemCount: context.state.products.length,
itemBuilder: (_, i) => ProductTile(context.state.products[i]),
);
}
Never return FutureOr<AppState?> directly. AsyncRedux must know if the action is sync or
async:
// CORRECT
Future<AppState?> reduce() async { ... }
// CORRECT
AppState? reduce() { ... }
// WRONG - throws StoreException
FutureOr<AppState?> reduce() { ... }
URLs from the documentation:
data-ai
Show loading states and handle action failures in widgets. Covers `isWaiting(ActionType)` for spinners, `isFailed(ActionType)` for error states, `exceptionFor(ActionType)` for error messages, and `clearExceptionFor()` to reset failure states.
data-ai
Use `waitCondition()` inside actions to pause execution until state meets criteria. Covers waiting for price thresholds, coordinating between actions, and implementing conditional workflows.
testing
Handle user-facing errors with UserException. Covers throwing UserException from actions, setting up UserExceptionDialog, customizing error dialogs with `onShowUserExceptionDialog`, and using UserExceptionAction for non-interrupting error display.
tools
Implement undo/redo functionality using state observers. Covers recording state history with stateObserver, creating a RecoverStateAction, implementing undo for the full state or partial state, and managing history limits.