watchable_redux 1.3.0 copy "watchable_redux: ^1.3.0" to clipboard
watchable_redux: ^1.3.0 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);

// Build reactive UI (two equivalent ways)
store.build((count) => Text('$count'));  // Full state
store.build((count) => Text('$count'), only: (s) => s.counter);  // Selected slice
store.select((s) => s).build((count) => Text('$count'));  // Chained

// 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(
        // Option 1: Full state (no selector)
        child: store.build((count) => Text('$count', style: TextStyle(fontSize: 48))),
        // Option 2: With selector
        // child: store.build((count) => Text('$count'), only: (s) => s),
        // Option 3: Chained (useful when you need the Watchable)
        // 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 Tips #

  1. Use memoized selectors for computed values
  2. Selector caching is automatic - same selector = same cached result
  3. Granular selects - select only what you need
  4. Avoid inline selectors in hot paths - define them once
  5. 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'));

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.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 state
  • dispatch(action) - Dispatch action
  • select<R>(selector) - Get cached AbstractWatchable<R> for selector
  • build<R>(builder, {only, when}) - Build reactive UI
    • only: Select slice of state to watch (auto-memoized)
    • when: Custom rebuild condition (auto-memoized)
  • devTools - Access time-travel DevTools
  • dispose() - Clean up resources

Selectors #

  • createSelector<S, I, R>(inputSelector, compute) - 1 input
  • createSelector2 through createSelector5 - 2-5 inputs

Middleware #

  • thunkMiddleware<S>() - Async action support
  • loggerMiddleware({logActions, logState}) - Debug logging
  • EffectsMiddleware<S>({effects}) - Stream-based side effects

DevTools #

  • undo(), redo(), jumpTo(index), reset(), clear()
  • canUndo, canRedo, history, currentIndex
  • watchableHistory, 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.

0
likes
160
points
11
downloads

Documentation

Documentation
API reference

Publisher

verified publisherdipendrasharma.com

Weekly Downloads

Predictable state management for Flutter. Redux architecture with O(1) selector caching, memoized selectors, async middleware, and time-travel debugging. Built on Watchable.

Repository (GitHub)
View/report issues

Topics

#state-management #redux #flutter #reactive #watchable

License

BSD-3-Clause (license)

Dependencies

flutter, watchable

More

Packages that depend on watchable_redux