watchable_redux 1.2.1
watchable_redux: ^1.2.1 copied to clipboard
Predictable state management for Flutter. Redux architecture with O(1) selector caching, memoized selectors, async middleware, and time-travel debugging. Built on Watchable.
Watchable Redux #
Predictable state management for Flutter. Built on Watchable.
// Define
final store = Store<int>(initialState: 0, reducer: counterReducer);
// Select with O(1) caching
store.select((s) => s).build((count) => Text('$count'));
// Update
store.dispatch(Increment());
Redux architecture with the simplicity of Watchable. Zero boilerplate, O(1) rebuilds.
Quick Start #
dependencies:
watchable_redux: ^1.2.0
import 'package:watchable_redux/watchable_redux.dart';
// 1. Define Actions
class Increment extends ReduxAction {
const Increment();
}
// 2. Define Reducer
int counterReducer(int state, ReduxAction action) {
return switch (action) {
Increment() => state + 1,
_ => state,
};
}
// 3. Create Store
final store = Store<int>(
initialState: 0,
reducer: counterReducer,
);
// Optional: Enable deep equality for collection comparison
// final store = Store<int>(
// initialState: 0,
// reducer: counterReducer,
// equals: deepEquals, // Prevents rebuilds for same-content collections
// );
// 4. Build UI
class CounterApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: store.select((s) => s).build(
(count) => Text('$count', style: TextStyle(fontSize: 48)),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => store.dispatch(const Increment()),
child: Icon(Icons.add),
),
);
}
}
Done. Predictable state with automatic UI updates.
Why Watchable Redux? #
| Feature | Watchable Redux | flutter_redux | redux.dart |
|---|---|---|---|
| Selector Caching | O(1) built-in | Manual | Manual |
| Boilerplate | Minimal | Moderate | Heavy |
| Memoized Selectors | Built-in | External pkg | External pkg |
| Async Actions | Thunk + Effects | Thunk only | Thunk only |
| Time-Travel | Built-in DevTools | External | External |
| Type Safety | Full generics | Good | Good |
| Learning Curve | 10 minutes | 30 minutes | 1 hour |
Core Concepts #
Actions #
// Simple action
class Increment extends ReduxAction {
const Increment();
}
// Action with payload
class SetValue extends ReduxAction {
final int value;
const SetValue(this.value);
}
// Sealed class for exhaustive matching
sealed class CounterAction extends ReduxAction {
const CounterAction();
}
class Add extends CounterAction {
final int amount;
const Add(this.amount);
}
class Reset extends CounterAction {
const Reset();
}
Reducers #
// Pattern matching reducer
int counterReducer(int state, ReduxAction action) {
return switch (action) {
Add(:final amount) => state + amount,
Reset() => 0,
_ => state,
};
}
// Complex state reducer
AppState appReducer(AppState state, ReduxAction action) {
return switch (action) {
LoginSuccess(:final user, :final token) => state.copyWith(
user: user,
token: token,
isLoading: false,
),
Logout() => AppState.initial(),
_ => state,
};
}
Store #
final store = Store<AppState>(
initialState: AppState.initial(),
reducer: appReducer,
middlewares: [thunkMiddleware(), loggerMiddleware()],
enableDevTools: true, // Time-travel debugging
);
// Access state
print(store.state);
// Dispatch actions
store.dispatch(const Increment());
// Select with caching
final counter = store.select((s) => s.counter);
Selectors #
Simple Selectors #
// Direct field access
final selectUser = (AppState s) => s.user;
final selectCart = (AppState s) => s.cart.items;
// Use in UI
store.select(selectUser).build((user) => Text(user.name));
Memoized Selectors #
// Cached computation - only recalculates when input changes
final selectTotal = createSelector<CartState, List<CartItem>, double>(
(s) => s.items,
(items) => items.fold(0.0, (sum, item) => sum + item.price * item.quantity),
);
// Compose multiple inputs
final selectFinalPrice = createSelector2<CartState, double, double, double>(
(s) => s.subtotal,
(s) => s.discount,
(subtotal, discount) => subtotal * (1 - discount),
);
// Chain selectors (up to 5 inputs)
final selectSummary = createSelector3<AppState, User?, List<CartItem>, double, String>(
(s) => s.user,
(s) => s.cart.items,
(s) => s.cart.total,
(user, items, total) => '${user?.name}: ${items.length} items, \$$total',
);
O(1) Selector Caching #
// Same selector = same cached result
final doubled1 = store.select((s) => s * 2);
final doubled2 = store.select((s) => s * 2);
print(identical(doubled1, doubled2)); // true - same cached instance
Middleware #
Thunk (Async Actions) #
final store = Store<AppState>(
initialState: AppState.initial(),
reducer: appReducer,
middlewares: [thunkMiddleware()],
);
// Dispatch async action
store.dispatch(ThunkAction<AppState>((dispatch, getState) async {
dispatch(SetLoading(true));
try {
final user = await api.fetchUser();
dispatch(SetUser(user));
} catch (e) {
dispatch(SetError(e.toString()));
} finally {
dispatch(SetLoading(false));
}
}));
Effects (Stream-based Side Effects) #
final effects = EffectsMiddleware<AppState>(effects: [
// Auto-save on data change
(action, store) {
if (action is DataChanged) {
return Stream.fromFuture(saveToStorage(store.state))
.map((_) => const SaveSuccess());
}
return null;
},
// Debounced search
(action, store) {
if (action is SearchQueryChanged) {
return Stream.fromFuture(
Future.delayed(Duration(milliseconds: 300))
.then((_) => api.search(action.query))
).map((results) => SearchResults(results));
}
return null;
},
]);
final store = Store<AppState>(
initialState: AppState.initial(),
reducer: appReducer,
middlewares: [effects.middleware],
);
// Don't forget to dispose
effects.dispose();
Logger #
final store = Store<int>(
initialState: 0,
reducer: counterReducer,
middlewares: [
loggerMiddleware(
logActions: true,
logState: true,
),
],
);
Custom Middleware #
Middleware<AppState> authMiddleware() {
return (store, action, next) {
// Block actions if not authenticated
if (action is ProtectedAction && store.state.token == null) {
store.dispatch(const RedirectToLogin());
return; // Block action
}
next(action); // Continue chain
};
}
DevTools (Time-Travel) #
final store = Store<int>(
initialState: 0,
reducer: counterReducer,
enableDevTools: true,
maxHistoryLength: 100,
);
// Navigate history
store.devTools?.undo();
store.devTools?.redo();
store.devTools?.jumpTo(5);
store.devTools?.reset();
// Query state
print(store.devTools?.canUndo); // true/false
print(store.devTools?.canRedo); // true/false
print(store.devTools?.history); // List<StateSnapshot>
print(store.devTools?.currentIndex); // Current position
// Build DevTools UI
store.devTools!.watchableHistory.build((history) =>
ListView.builder(
itemCount: history.length,
itemBuilder: (_, i) => ListTile(
title: Text('State $i'),
subtitle: Text('${history[i].state}'),
onTap: () => store.devTools!.jumpTo(i),
),
),
);
Provider #
StoreProvider #
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StoreProvider<AppState>(
store: store,
child: MaterialApp(home: HomePage()),
);
}
}
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final store = StoreProvider.of<AppState>(context);
// or
final store = context.store<AppState>();
return store.select((s) => s.user).build(
(user) => Text('Hello, ${user.name}'),
);
}
}
Global Redux Singleton #
void main() {
Redux.init(store);
runApp(MyApp());
}
// Access anywhere
Redux.dispatch(const Increment());
print(Redux.state<AppState>());
Real-World Examples #
Authentication Flow #
// State
class AuthState {
final User? user;
final String? token;
final bool isLoading;
final String? error;
const AuthState({
this.user,
this.token,
this.isLoading = false,
this.error,
});
}
// Actions
sealed class AuthAction extends ReduxAction {}
class LoginRequest extends AuthAction {
final String email, password;
LoginRequest(this.email, this.password);
}
class LoginSuccess extends AuthAction {
final User user;
final String token;
LoginSuccess(this.user, this.token);
}
class LoginFailure extends AuthAction {
final String error;
LoginFailure(this.error);
}
class Logout extends AuthAction {}
// Reducer
AuthState authReducer(AuthState state, ReduxAction action) {
return switch (action) {
LoginRequest() => state.copyWith(isLoading: true, error: null),
LoginSuccess(:final user, :final token) => AuthState(user: user, token: token),
LoginFailure(:final error) => state.copyWith(isLoading: false, error: error),
Logout() => const AuthState(),
_ => state,
};
}
// Thunk
void login(String email, String password) {
store.dispatch(ThunkAction<AuthState>((dispatch, getState) async {
dispatch(LoginRequest(email, password));
try {
final result = await authApi.login(email, password);
dispatch(LoginSuccess(result.user, result.token));
} catch (e) {
dispatch(LoginFailure(e.toString()));
}
}));
}
// UI
store.select((s) => s.isLoading).build((loading) =>
loading ? CircularProgressIndicator() : LoginForm()
);
Shopping Cart #
// State
class CartState {
final List<CartItem> items;
final String? coupon;
final double discount;
double get subtotal => items.fold(0, (s, i) => s + i.price * i.quantity);
double get total => subtotal * (1 - discount);
}
// Selectors
final selectItemCount = createSelector<CartState, List<CartItem>, int>(
(s) => s.items,
(items) => items.fold(0, (sum, i) => sum + i.quantity),
);
final selectTotalPrice = createSelector2<CartState, double, double, double>(
(s) => s.subtotal,
(s) => s.discount,
(subtotal, discount) => subtotal * (1 - discount),
);
// UI
Column(children: [
store.select(selectItemCount).build((count) =>
Text('$count items in cart')
),
store.select(selectTotalPrice).build((total) =>
Text('Total: \$${total.toStringAsFixed(2)}')
),
]);
Architecture Patterns #
Feature-Based Structure #
lib/
features/
auth/
auth_actions.dart
auth_reducer.dart
auth_selectors.dart
auth_middleware.dart
cart/
cart_actions.dart
cart_reducer.dart
cart_selectors.dart
store/
app_state.dart
app_reducer.dart
store.dart
Combine Reducers #
Map<String, dynamic> appReducer(Map<String, dynamic> state, ReduxAction action) {
return combineReducers({
'auth': (s, a) => authReducer(s ?? AuthState(), a),
'cart': (s, a) => cartReducer(s ?? CartState(), a),
'settings': (s, a) => settingsReducer(s ?? SettingsState(), a),
})(state, action);
}
Performance #
State Management Comparison #
Comprehensive benchmarks comparing watchable_redux with other popular Flutter state management solutions.
Throughput (Operations/Second)
| Listeners | ValueNotifier | Provider | Riverpod | watchable_redux |
|---|---|---|---|---|
| 0 | 24.9M | 23.5M | 20.1M | 18.5M |
| 10 | 14.3M | 14.3M | 6.1M | 19.1M ✓ |
| 100 | 3.0M | 3.0M | 912K | 18.9M ✓ |
Key Finding: watchable_redux is the fastest with multiple listeners! With 100 listeners, it's 6x faster than ValueNotifier due to O(1) selector caching and optimized Watchable internals using
identical()comparison.
Widget Rebuild Efficiency
After updating only auth state in a widget tree with 3 widgets (auth, theme, posts):
| Library | Auth | Theme | Posts | Total Rebuilds |
|---|---|---|---|---|
| ValueNotifier | 1 | 1 | 1 | 3 ✗ |
| Provider | 1 | 0 | 0 | 1 ✓ |
| Riverpod | 1 | 0 | 0 | 1 ✓ |
| watchable_redux | 1 | 0 | 0 | 1 ✓ |
Key Finding: ValueNotifier rebuilds ALL widgets on ANY state change. Provider/Riverpod/watchable_redux rebuild only affected widgets.
Feature Comparison
| Feature | ValueNotifier | Provider | Riverpod | watchable_redux |
|---|---|---|---|---|
| Raw Update Speed | ★★★★☆ | ★★★★☆ | ★★★☆☆ | ★★★★★ |
| Selective Rebuilds | ☆☆☆☆☆ | ★★★★☆ | ★★★★★ | ★★★★★ |
| Boilerplate | ★★★★★ | ★★★★☆ | ★★★☆☆ | ★★★★☆ |
| Type Safety | ★★★★★ | ★★★★☆ | ★★★★★ | ★★★★★ |
| DevTools/Time-Travel | ☆☆☆☆☆ | ☆☆☆☆☆ | ★★★☆☆ | ★★★★★ |
| Async Support | ☆☆☆☆☆ | ★★☆☆☆ | ★★★★☆ | ★★★★★ |
| Testing | ★★★★★ | ★★★★☆ | ★★★★★ | ★★★★★ |
| Learning Curve | ★★★★★ | ★★★★☆ | ★★★☆☆ | ★★★★☆ |
| Middleware/Effects | ☆☆☆☆☆ | ☆☆☆☆☆ | ★★★☆☆ | ★★★★★ |
When to Use Each
| Solution | Best For |
|---|---|
| ValueNotifier | Simple counters, form fields, local widget state |
| Provider | Medium apps, gradual adoption, InheritedWidget replace |
| Riverpod | Complex apps, heavy testing needs, compile-time safety |
| watchable_redux | Enterprise apps, debugging needs, Redux familiarity |
Internal Benchmarks #
All operations are optimized for O(1) time complexity where possible.
| Operation | Performance | Notes |
|---|---|---|
| Dispatch (simple state) | ~1μs | Single int state |
| Dispatch (complex state) | <1μs | Nested objects |
| Dispatch (10k list) | ~41μs | Immutable append |
| Selector lookup | O(1) | Constant regardless of cache size |
| Selector chain (20 deep) | <1μs | Memoization eliminates overhead |
| Undo/Redo | O(1) | Instant time-travel |
| JumpTo | O(1) | Direct index access |
Stress Test Results #
| Test | Result |
|---|---|
| 100,000 dispatches | 16-23ms |
| 50,000 mixed actions | 5-7ms |
| 100 dispatches (1MB state) | 55ms |
| 10k selector chain calls (25 deep) | 2ms |
| 10k undo/redo pairs | 7ms |
| 10k jumpTo operations | 1ms |
| 100 stores × 1000 dispatches | 12ms |
DevTools Overhead #
- Without DevTools: ~18.9M ops/sec
- With DevTools (100 history): ~1.5M ops/sec
- Overhead: ~92% (expected due to history recording)
Note: DevTools should only be enabled during development.
Performance Tips #
- Use memoized selectors for computed values
- Selector caching is automatic - same selector = same cached result
- Granular selects - select only what you need
- Avoid inline selectors in hot paths - define them once
- Disable DevTools in production for maximum throughput
// Good - defined once, cached
final selectUserName = (AppState s) => s.user?.name;
store.select(selectUserName).build((name) => Text(name ?? 'Guest'));
// Avoid in hot rebuild paths - creates new selector each time
store.select((s) => s.user?.name).build((name) => Text(name ?? 'Guest'));
Run Benchmarks #
# Run state management comparison
flutter test test/performance/state_management_comparison_test.dart
# Run all performance tests
flutter test test/performance/
Testing #
Test Coverage #
The package includes 327+ tests covering:
| Category | Tests | Coverage |
|---|---|---|
| Store | ~25 | Creation, dispatch, dispose |
| Selectors | ~30 | Memoization, caching, chaining |
| Middleware | ~25 | Thunk, effects, custom |
| DevTools | ~35 | Time-travel, history |
| Reducers | ~40 | All types, combineReducers |
| Provider | ~20 | Widget tree, context |
| Integration | ~25 | Full workflows |
| Performance | ~60 | O(1) verification, benchmarks |
| Edge Cases | ~32 | Error handling, concurrency |
| Thread Safety | ~15 | Race conditions, torn reads |
Run tests:
flutter test
Writing Tests #
test('reducer handles increment', () {
expect(counterReducer(0, const Increment()), equals(1));
});
test('selector memoization', () {
var computeCount = 0;
final selector = createSelector<int, int, int>(
(s) => s,
(s) { computeCount++; return s * 2; },
);
selector(5);
selector(5);
expect(computeCount, equals(1)); // Computed only once
});
testWidgets('store updates UI', (tester) async {
final store = Store<int>(initialState: 0, reducer: counterReducer);
await tester.pumpWidget(
MaterialApp(
home: store.select((s) => s).build((count) => Text('$count')),
),
);
expect(find.text('0'), findsOneWidget);
store.dispatch(const Increment());
await tester.pump();
expect(find.text('1'), findsOneWidget);
});
Migration from flutter_redux #
// flutter_redux
StoreProvider<AppState>(
store: store,
child: StoreConnector<AppState, int>(
converter: (store) => store.state.counter,
builder: (context, count) => Text('$count'),
),
)
// watchable_redux
StoreProvider<AppState>(
store: store,
child: Builder(
builder: (context) => context.store<AppState>()
.select((s) => s.counter)
.build((count) => Text('$count')),
),
)
API Reference #
Store #
Store<S>({initialState, reducer, middlewares, enableDevTools, maxHistoryLength})state- Current statedispatch(action)- Dispatch actionselect<R>(selector)- Get cached watchable for selectordevTools- Access time-travel DevToolsdispose()- Clean up resources
Selectors #
createSelector<S, I, R>(inputSelector, compute)- 1 inputcreateSelector2throughcreateSelector5- 2-5 inputs
Middleware #
thunkMiddleware<S>()- Async action supportloggerMiddleware({logActions, logState})- Debug loggingEffectsMiddleware<S>({effects})- Stream-based side effects
DevTools #
undo(),redo(),jumpTo(index),reset(),clear()canUndo,canRedo,history,currentIndexwatchableHistory,watchableIndex- Reactive history
Provider #
StoreProvider<S>({store, child})StoreProvider.of<S>(context),StoreProvider.maybeOf<S>(context)context.store<S>(),context.dispatch<S>(action)Redux.init(store),Redux.dispatch(action),Redux.state<S>()
License #
BSD-3-Clause. See LICENSE.
Built with Watchable - The simplest Flutter state management.