flutter_rx

A redux style state management library, loosely inspired by NgRx as well as flutter_redux and redux.

Key concepts

  • Actions describe unique events and are typically dispatched from widgets or returned from an Effect, such as a button click or the intent to load data from a server.
  • State changes are handled by pure functions called reducers that take the current state and the latest action to compute a new state.
  • Selectors are pure functions used to select, derive, and compose pieces of state.
  • State is accessed with the Store, an observable of state and an observer of actions.
  • Effects are used to handle any and all side effects, such as loading data from a remote server.

Diagram

The following diagram represents the overall general flow of application state in flutter_rx. flutter_rx Diagram

Usage

Below is a counter app that uses flutter_rx

import 'package:flutter/material.dart';
import 'package:flutter_rx/flutter_rx.dart';
import 'package:rxdart/rxdart.dart';

/// The State of the Application.
///
/// The state must extend [StoreState], and should provide
/// overrides for [==] and [hashCode].
///
/// If a [==] override is not provided, selection memoization
/// will not work, which will lead to unnecessary rebuilds.
class AppState extends StoreState {
  const AppState({required this.counter, this.isLoading = false});
  final int counter;
  final bool isLoading;

  AppState copyWith({int? counter, bool? isLoading}) {
    return AppState(
      counter: counter ?? this.counter,
      isLoading: isLoading ?? this.isLoading,
    );
  }

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    if (other.runtimeType != runtimeType) return false;
    return other is AppState &&
        other.counter == counter &&
        other.isLoading == isLoading;
  }

  @override
  int get hashCode => counter.hashCode ^ isLoading.hashCode;
}

/// Actions describe unique events and are typically dispatched
/// from widgets or returned from an [Effect].
///
/// Actions must extend [StoreAction].
class IncrementAction extends StoreAction {
  const IncrementAction();
}

/// Actions can optionally contain state.
class IncrementByAction extends StoreAction {
  const IncrementByAction({required this.value});
  final int value;
}

/// Actions can be used to initiate async tasks, such
/// as fetching data from a server.
class FetchCounterValueAction extends StoreAction {
  const FetchCounterValueAction();
}

class FetchCounterValueSuccessAction extends StoreAction {
  const FetchCounterValueSuccessAction({required this.value});
  final int value;
}

/// A reducer is just a pure function that takes in a state and
/// an action, and returns a new state.
///
/// The reducers below are intended to reduce on a specific
/// action, for example [IncrementAction], but reducers can
/// also reduce on any generic [StoreAction].
AppState incrementCounterReducer(
  AppState state,
  IncrementAction action,
) {
  return state.copyWith(counter: state.counter + 1);
}

AppState incrementCounterByReducer(
  AppState state,
  IncrementByAction action,
) {
  return state.copyWith(counter: state.counter + action.value);
}

AppState fetchCounterValueReducer(
  AppState state,
  FetchCounterValueAction action,
) {
  return state.copyWith(
    isLoading: true,
  );
}

AppState fetchCounterValueSuccessReducer(
  AppState state,
  FetchCounterValueSuccessAction action,
) {
  return state.copyWith(
    counter: action.value,
    isLoading: false,
  );
}

/// [createReducer] takes multiple single purpose reducers and combines them.
///
/// [On] is used to map a specific [StoreAction] to the reducer that
/// should be used when that action is dispatched.
///
/// For example, when [IncrementAction] is received, [incrementCounterReducer]
/// will be used to generate the new state.
final reducer = createReducer<AppState>([
  On<AppState, IncrementAction>(incrementCounterReducer),
  On<AppState, IncrementByAction>(incrementCounterByReducer),
  On<AppState, FetchCounterValueAction>(fetchCounterValueReducer),
  On<AppState, FetchCounterValueSuccessAction>(fetchCounterValueSuccessReducer),
]);

/// Effects handle any and all side effects, such as fetching data from a
/// remote  server.
///
/// Effects receive the stream of actions from the store. Typically
/// this stream is filtered for a specific action.
///
/// Actions are only added to the stream that effects receive after
/// the app's reducers has processed this action and returned a new app state.
/// This guarantees that the state of the store includes mutations from the action
/// being handled by the effect.
///
/// Effects can optionally return one or more actions. These actions
/// will then be dispatched. If the effect returns a list of actions,
/// will be dispatched in the order of the list.
///
/// Effects should **not** call dispatch to dispatch new actions.
Effect<AppState> onFetchCounter = (
  Stream<StoreAction> actions,
  Store<AppState> store,
) {
  /// RxDart can be useful within effects, but does not need to be used.
  return actions.whereType<FetchCounterValueAction>().flatMap((action) {
    return Stream.fromFuture(fetchCounter()).map((value) {
      return FetchCounterValueSuccessAction(value: value);
    });
  });
};

/// Mock data call.
///
/// In a real app this would be a network call.
Future<int> fetchCounter() {
  return Future.delayed(const Duration(milliseconds: 100)).then((value) => 10);
}

void main() {
  /// Create your store as a final variable in the main function
  /// or inside a State object.
  final store = Store<AppState>(
    /// The initial state of the store.
    initialState: const AppState(counter: 0),

    /// The main reducer.
    ///
    /// This can be a simple pure function, or composed of multiple smaller reducers.
    reducer: reducer,

    /// The list of effects to be registered.
    effects: [onFetchCounter],
  );

  runApp(
    /// The StoreProvider should generally be the top level widget. Only descendants
    /// will have access to the StoreProvider's state.
    StoreProvider<AppState>(
      store: store,
      child: const MaterialApp(
        home: HomePage(),
      ),
    ),
  );
}

class HomePage extends StatelessWidget {
  const HomePage({
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('FlutterRx Counter Demo'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text(
              'You have pushed the button this many times:',
            ),

            /// StoreConnector can be used to access data from the store
            /// using a selector.
            ///
            /// You do not have to use StoreConnector. Data can be streamed from the
            /// store using `StoreProvider.of<AppState>(context)`, and the current
            /// snapshot of the store can be read with
            /// `StoreProvider.of<AppState>(context).value`.
            StoreConnector(
              /// The selector maps the apps state to a subset for use in your
              /// widget.
              ///
              /// A simple function could be used in place of createSelector,
              /// but createSelector uses memoization to reduce rebuilds.
              selector: createSelector((AppState state, _) => state.counter),

              /// onInit can be used execute code on the first build. This is
              /// often useful for dispatching an action that fetches data for
              /// view.
              onInit: () {
                StoreProvider.of<AppState>(context).dispatch(
                  const FetchCounterValueAction(),
                );
              },

              /// The builder will only rebuild when when the selector emits
              /// a new value, and memoization ensures that this only happens when
              /// the app state has changed.
              ///
              /// createSelector1, createSelector2, etc. can be used to compose
              /// selectors together. The composed selector will only emit a new value
              /// when one of the input selectors does.
              builder: (BuildContext context, int value) {
                return Text(
                  '$value',
                  style: Theme.of(context).textTheme.headline4,
                );
              },
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // Use the StoreProvider to get the store and call the dispatch method
          // to dispatch actions.
          StoreProvider.of<AppState>(context).dispatch(
            const IncrementAction(),
          );
        },
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

Libraries

flutter_rx