redux_machine 1.0.0-rc.1

Build Status Pub Pub latest

Originally started to provide implementation of a State Machine using Redux design pattern, this library now includes a Redux Store which can be used as a regular state store or a state machine.

This library implements simplified action dispatch flow:

  1. User dispatches an action
  2. Store executes corresponding reducer function, synchronously.
  3. Store publishes a StoreEvent as a result into events stream.
  4. Side-effects are handled in stream listeners subscribed to events stream.

Since reducers are pure functions and there is no middleware layer - dispatching an action is always side-effect free and always results in an event published to events stream (unless reducer function resulted in an error).

Any middleware-like logic should be put in events stream listeners.

Upgrading from 0.1.x? See UPGRADING.md for instructions.

Usage #

TL;DR see full source code of this example in the example/ folder.

Redux requires three things: state, actions and reducers.

We start by defining our state object. Below is an example of a coin-operated turnstile (from Wikipedia):

class Turnstile {
  final bool isLocked;
  final int coinsCollected;
  final int visitorsPassed;

  Turnstile(this.isLocked, this.coinsCollected, this.visitorsPassed);

  /// Convenience method to use in reducers.
  Turnstile copyWith({
    bool isLocked,
    int coinsCollected,
    int visitorsPassed,
  }) {
    return new Turnstile(
      isLocked ?? this.isLocked,
      coinsCollected ?? this.coinsCollected,
      visitorsPassed ?? this.visitorsPassed,
    );
  }
}

Next, actions:

abstract class Actions {
  /// Put coin to unlock turnstile
  static const putCoin = const VoidActionBuilder('putCoin');

  /// Push turnstile to pass through
  static const push = const VoidActionBuilder('push');
}

There are 4 action builder classes provided for most common use cases. Above example shows usage of VoidActionBuilder which creates Actions with no (void) payload. Here are examples of using all 4 builders:

abstract class Actions {
  /// The same example as above.
  static const putCoin = const VoidActionBuilder('putCoin');

  /// Regular action builder with [String] payload. Payload is required.
  static const getUser = const ActionBuilder<String>('getUser');

  /// The same as [VoidActionBuilder] but creates [AsyncAction] with no payload.
  /// Read more on async actions below in this document.
  static const clearCache = const AsyncVoidActionBuilder('clearCache');

  /// Similarly creates [AsyncAction] with required payload of type [String].
  static const deleteUser = const AsyncActionBuilder<String>('deleteUser');
}

/// Using builders
Future<void> main() async {
  final Store<MyState> store = getStore();
  /// All builders implement [call] method and can be invoked as a regular
  /// function. [call] method of `void` builders has zero arguments.
  store.dispatch(Actions.putCoin());

  /// `Async*` builders create [AsyncAction]s which allow dispatching side
  /// to know when action is completed via [AsyncAction.done] Future.
  final clearCache = Actions.clearCache();
  store.dispatch(clearCache);
  await clearCache.done;

  /// [ActionBuilder] and [AsyncActionBuilder] implement `call` method with
  /// single argument - payload for the created action:
  store.dispatch(Actions.getUser('user-id'));
  /// Or:
  final deleteUser = Actions.deleteUser('user-id');
  store.dispatch(deleteUser);
  await deleteUser.done;
}

Next step, reducers:

Turnstile putCoinReducer(Turnstile state, Action<void> action) {
  int coinsCollected = state.coinsCollected + 1;
  print('Coins collected: $coinsCollected');
  return state.copyWith(isLocked: false, coinsCollected: coinsCollected);
}

Turnstile pushReducer(Turnstile state, Action<void> action) {
  int visitorsPassed = state.visitorsPassed;
  if (!state.isLocked) {
    visitorsPassed++;
    print('Visitors passed: ${visitorsPassed}');
  }
  return state.copyWith(isLocked: true, visitorsPassed: visitorsPassed);
}

Combining everything together:

void main() {
  // Create Redux store and register reducers using provided builder class:
  final builder = new StoreBuilder<Turnstile>(
    initialState: new Turnstile(true, 0, 0),
  );
  builder
    ..bind(Actions.putCoin, putCoinReducer)
    ..bind(Actions.push, pushReducer);
  final store = builder.build();

  // Try triggering some actions
  machine.dispatch(Actions.push());
  machine.dispatch(Actions.putCoin());
  // .. etc.
  // Make sure to dispose the machine in the end:
  machine.dispose();
}

Chaining actions #

Sometimes it is useful to trigger another action from inside current reducer. It is possible via Action.next() method:

State exampleReducer(State state, Action<void> action) {
  // do work here
  // ...
  final newState = state.copyWith(exampleField: 'value');
  // State store will dispatch `otherAction` and pass `newState` as an input
  // state argument.
  return action.next(newState, Actions.otherAction());
}

Note that Action.next does not perform actual dispatch so calling it multiple times within a reducer function has no chaining effect. Only action passed to the last invocation of Action.next will be dispatched by the state store.

Middleware example 1: logging #

Store class exposes events stream which contains all dispatched actions and their results. Logging middleware becomes a simple stream subscription. Below is simplistic printing to stdout of all events:

final Store<MyState> store = getStore();
// Print all events to stdout:
store.events.listen(print);

Middleware example 2: error reporting #

Any unhandled errors in reducers are forwarded to the errors stream if there is an active listener on it. If there is no active listener all errors are simply rethrown during dispatch.

Note that Store.errors stream contains instances of StoreError which provide details about the failed action and current state. In case there is no listener on this stream the unhandled error from reducer is rethrown as-is (not wrapped with StoreError) to preserve original stack trace.

To log all unhandled errors listen on the "errors" stream.

final Store<MyState> store = getStore();
// Print all events to stdout:
store.errors.listen(null, onError: errorHandler, cancelOnError: false);

void errorHandler(error, stackTrace) {
  print(error);
  print(stackTrace);
}

Actions which resulted in an error do not publish a StoreEvent to the events stream.

Middleware example 3: making HTTP request #

In below example we use Store.eventsFor stream which returns a stream of events produced by the same action type (in this case all "fetchUser" events).

final Store<MyState> store = getStore();
// Note that async is allowed in event listeners.
store.eventsFor(Actions.fetchUser).listen((StoreEvent<MyState, String> event) async {
  try {
    // assuming action payload is the ID of a user to fetch.
    String userId = event.action.payload;
    final user = await fetchUserFromHttpApi(userId);
    store.dispatch(Actions.userFetched(user));
  } catch (error) {
    store.dispatch(Actions.userFetchFailed(error));
  }
});

store.dispatch(Actions.fetchUser('user-id-here'));

Async actions (experimental) #

AsyncAction is like regular Redux Action except it also carries a Future. In many cases it can be a simpler alternative to traditional trio of doFoo, doFooSuccess and doFooFailed actions.

Common use case for async actions is when no explicit UI interaction is expected with the user after the action is done. For intance, deleting content or swiping list items left or right.

Using async actions #

Async actions assume there are side-effects involved so they are normally handled by an event stream listener where side-effects are allowed:

abstract class Actions {
  /// Action payload is an integer ID of the note to delete.
  static const deleteNote = const AsyncActionBuilder<int>('deleteNote');
}

// Subscribing to deleteNote events.
Store buildStore() {
  final builder = new StoreBuilder<AppState>();
  // ...bind reducers
  final store = builder.build();
  store.eventsFor(Actions.deleteNote).listen(_deleteNote);
}

/// Listener for deleteNote events
_deleteNote(StoreEvent<AppState, int> event) async {
  AsyncAction<int> action = event.action;
  int noteId = action.payload;
  try {
    var result = await httpClient.send('DELETE', '/notes/$noteId');
    // Delete successful, mark the action as done
    action.complete();
  } catch (error) {
    action.completeError(error);
  }
}

// Somewhere on the client side where the action is dispatched
deleteNoteButtonPressed(int noteId) async {
  final action = Actions.deleteNote(noteId);
  store.dispatch(action);
  // refresh UI to show loading state, pseudo-code
  setState(isLoading: true);
  try {
    await action.done;
    setState(isLoading: false); // refresh UI, delete successful
  } catch (error) {
    // failed to delete, show the error.
    setState(errorMessage: error.toString());
  }
}

Features and bugs #

Please file feature requests and bugs at the issue tracker.

1.0.0-rc.1 #

  • Added: Store.isDisposed getter.
  • Breaking: Removed StateMachine, MachineState and StateMachineBuilder classes which were deprecated in 1.0.0-beta.1. See UPGRADING.md for more details.

1.0.0-beta.1 #

  • Added: Action.next() as a substitute to existing MachineState.nextAction. The regular Store class now also supports behavior of StateMachine, which makes StateMachine obsolete (will be removed before stable release).
  • Deprecated: StateMachine and related classes (MachineState, StateMachineBuilder). Regular Store class can now be used as a state machine. See UPGRADING.md for more details.

1.0.0-dev.1.0 #

This version is designed to work with Dart 2 and includes many changes to provide better static analysis in most cases.

  • Breaking: Depends on Dart SDK 2.0.0-dev
  • Breaking: Removed deprecated ReduxMachine and related classes.
  • Breaking: removed onError handler on StoreBuilder and StateMachineBuilder Unhandled errors from reducers are propagated to the new Store.errors stream if there is an active listener on it. If there is no active listener then errors are simply rethrown synchronously.
  • Breaking: StateMachine now requires state objects to extend MachineState base class. See documentation for more details on how to use it.
  • Breaking: Removed ActionDispatcher, StateMachineReducer interfaces. StateMachine uses regular Reducer interface now.
  • Breaking: Removed StoreErrorHandler definition.
  • Breaking: ActionBuilder.call changed payload argument from optional to required. Use new VoidActionBuilder for actions without any payload.
  • Fixed: strong mode issues with Dart 2.
  • Fixed: stack trace propagation in case of errors originated in reducers.
  • Experimental: AsyncAction which allows dispatching code to know when it completes and if there was an error. Corresponding AsyncActionBuilder and AsyncVoidActionBuilder were introduced as well.

0.1.2 #

  • Added onError argument to StoreBuilder and StateMachineBuilder.
  • Fixed: don't swallow errors in action dispatch flow.
  • Removed StoreError class.

0.1.1 #

  • Added type argument to StoreEvent for the action payload type for better static analysis.
  • Added store field to StoreEvent which contains reference to the state Store (or StateMachine) which produced that event.
  • Added Store.changesFor to allow listening for changes on a part of the application state.

0.1.0 #

  • Deprecated ReduxMachine implementation in favor of new StateMachine class.
  • Added separate Redux Store implementation which can be used on its own. New StateMachine uses Store internally for state management.
  • Updated readme with some details on side-effects handling in this library.

0.0.1 #

  • Initial version, created by Stagehand

example/main.dart

// Copyright (c) 2017, Anatoly Pulyaevskiy. All rights reserved. Use of this source code
// is governed by a BSD-style license that can be found in the LICENSE file.

import 'package:redux_machine/redux_machine.dart';

/// Implementation of a simple coin-operated turnstile state machine
/// as described here:
/// https://en.wikipedia.org/wiki/Finite-state_machine#Example:_coin-operated_turnstile
void main() {
  // Create our machine and register reducers:
  final builder = new StoreBuilder<Turnstile>(
    initialState: new Turnstile(true, 0, 0),
  );
  builder
    ..bind(Actions.putCoin, putCoinReducer)
    ..bind(Actions.push, pushReducer);
  final store = builder.build();

  // Try triggering some actions
  store.dispatch(Actions.push());
  store.dispatch(Actions.putCoin());
  store.dispatch(Actions.push());
  store.dispatch(Actions.push());
  store.dispatch(Actions.push());
  store.dispatch(Actions.push());
  store.dispatch(Actions.putCoin());
  store.dispatch(Actions.putCoin());
  store.dispatch(Actions.push());
  store.dispatch(Actions.push());
  // .. etc.
  // Make sure to dispose the state store in the end:
  store.dispose();
}

class Turnstile {
  final bool isLocked;
  final int coinsCollected;
  final int visitorsPassed;

  Turnstile(this.isLocked, this.coinsCollected, this.visitorsPassed);

  /// Convenience method to use in reducers.
  Turnstile copyWith({
    bool isLocked,
    int coinsCollected,
    int visitorsPassed,
  }) {
    return new Turnstile(
      isLocked ?? this.isLocked,
      coinsCollected ?? this.coinsCollected,
      visitorsPassed ?? this.visitorsPassed,
    );
  }
}

abstract class Actions {
  /// Put coin to unlock turnstile
  static const putCoin = const VoidActionBuilder('putCoin');

  /// Push turnstile to pass through
  static const push = const VoidActionBuilder('push');
}

Turnstile putCoinReducer(Turnstile state, Action<void> action) {
  int coinsCollected = state.coinsCollected + 1;
  print('Coins collected: $coinsCollected');
  return state.copyWith(isLocked: false, coinsCollected: coinsCollected);
}

Turnstile pushReducer(Turnstile state, Action<void> action) {
  int visitorsPassed = state.visitorsPassed;
  if (!state.isLocked) {
    visitorsPassed++;
    print('Visitors passed: ${visitorsPassed}');
  }
  return state.copyWith(isLocked: true, visitorsPassed: visitorsPassed);
}

Use this package as a library

1. Depend on it

Add this to your package's pubspec.yaml file:


dependencies:
  redux_machine: ^1.0.0-rc.1

2. Install it

You can install packages from the command line:

with pub:


$ pub get

with Flutter:


$ flutter pub get

Alternatively, your editor might support pub get or flutter pub get. Check the docs for your editor to learn more.

3. Import it

Now in your Dart code, you can use:


import 'package:redux_machine/redux_machine.dart';
  
Popularity:
Describes how popular the package is relative to other packages. [more]
0
Health:
Code health derived from static analysis. [more]
100
Maintenance:
Reflects how tidy and up-to-date the package is. [more]
68
Overall:
Weighted score of the above. [more]
43
Learn more about scoring.

We analyzed this package on Jul 11, 2019, and provided a score, details, and suggestions below. Analysis was completed with status completed using:

  • Dart: 2.4.0
  • pana: 0.12.19

Platforms

Detected platforms: Flutter, web, other

No platform restriction found in primary library package:redux_machine/redux_machine.dart.

Health suggestions

Fix lib/src/store.dart. (-0.50 points)

Analysis of lib/src/store.dart reported 1 hint:

line 280 col 9: DO use curly braces for all flow control structures.

Maintenance suggestions

Package is getting outdated. (-27.12 points)

The package was last published 66 weeks ago.

Package is pre-release. (-5 points)

Pre-release versions should be used with caution; their API can change in breaking ways.

Dependencies

Package Constraint Resolved Available
Direct dependencies
Dart SDK >=2.0.0-dev <3.0.0
Dev dependencies
test ^0.12.0

Admin