Live Cells is a reactive programming library for Dart, intended to be used with Flutter. Specifically Live Cells provides a replacement (the cell) for ChangeNotifier and ValueNotifier that is simpler to use and more flexible.

Features

Cells offers the following benefits over ChangeNotifier / ValueNotifier:

  • Implementing a cell which is an expression of other cells, e.g. a + b, can be done in a functional manner without manually adding and removing listeners.
  • Simpler resource management, no need to call dispose.
  • Integrated with a library of widgets which allow their properties to be controlled and observed by cells. This allows for a style of programming which fits in with the reactive paradigm of Flutter.
  • Effortless state restoration with minimal boilerplate.

This library also has the following advantages over other similar libraries:

  • Supports two-way data flow, whereas most other libraries, if not all, only support one-way data flow.
  • Cells are designed to be unobtrusive and indistinguishable, as much as is possible, from the values they hold.
  • Live Cells is unopinionated. You're not forced to change the way you write your apps. Use as much of its functionality as you need.
  • Integrated with a library of widgets which allow for effortless data binding between UI elements and cells.

Getting Started

If you haven't used Live Cells before, please head to the full documentation.

The remainder of this README shows brief examples that demonstrate the main features of this library.

Usage

Defining Cells

Constant cells can be defined either using .cell or ValueCell.value:

final a = 1.cell;
final b = 'hello'.cell;
final c = ValueCell.value(someValue);

Mutable cells are defined using MutableCell:

final a = MutableCell(0);
final b = MutableCell(1);

Computed Cells

Basic computed cells can be defined directly as an expression of cells:

final sum = a + b;

More complex computed cells are defined using ValueCell.computed:

final computed = ValueCell.computed(() => sqrt(a() * a() + b() * b()));

The values of computed cells are recomputed whenever the values of their argument cells change:

print(sum.value); // 1
a.value = 6;
print(sum.value); // 7

Observing Cells

Cells can be observed using ValueCell.watch. The watch function is called whenever the values of the cells it references change:

ValueCell.watch(() {
  print('${a()} + ${b()} = ${sum()}');
});

a.value = 8; // Prints: 8 + 1 = 9

Batch Updates

The values of multiple mutable cells can be set simultaneously using MutableCell.batch:

MutableCell.batch(() {
  a.value = 1;
  b.value = 3;
});

Cells in Widgets

CellWidget.builder creates a widget that is rebuilt whenever the values of the cells referenced by it change:

// Rebuilt when a, b, or sum change
CellWidget.builder(() => Text('${a()} + ${b()} = ${sum()}'));

Exception Handling

Exceptions thrown during the computation of a cell's value are propagated to all points where the value is referenced.

This allows exceptions to be handled using try catch:

final str = MutableCell('0');
final n = ValueCell.computed(() => int.parse(str()));

final isValid = ValueCell.computed(() {
  try {
    return n() > 0;
  }
  catch (e) {
    return false;
  }
});

print(isValid.value); // Prints false

str.value = '5';
print(isValid.value); // Prints true

str.value = 'not a number';
print(isValid.value); // Prints false

Or more succinctly using onError:

final str = MutableCell('0');
final n = ValueCell.computed(() => int.parse(str()));
final isValid = (n > 0.cell).onError(false.cell);

Previous Values

The previous value of a cell can be accessed using .previous, which is itself a cell:

final a = MutableCell(0);
final prev = a.previous;
final sum = a + prev;

a.value = 1;
print(a.value);    // Prints 1
print(prev.value); // Prints 0
print(sum.value);  // Prints 1

a.value = 5;
print(a.value);    // Prints 5
print(prev.value); // Prints 1
print(sum.value);  // Prints 6

Binding Cells to Widget Properties

This package also provides a collection of widgets, which allow their properties to be controlled and observed by cells:

For example LiveSwitch is this library's equivalent of Switch:

final state = MutableCell(false);

return Column(
  children: [
    // This binds the value of the switch to `state`
    LiveSwitch(
      value: state
    );
    // This widget is rebuilt whenever the switch is toggled
    CellWidget.builder(() => Text(state() ? 'On' : 'Off'));
  ]
);

Resetting the switch is as simple as setting the value of state:

state.value = false;

Two-Way Data Flow

Two-way data flow allows for complex logic to be implemented entirely using concise declarative code:

final n = MutableCell<num>(0);

// LiveTextField is a Live Cells TextField
//
// This binds the content of the field to `n`
return LiveTextField(
  // Example of two-way data flow:
  //
  // When the value of `n` is set, `mutableString()` converts it to
  // a string and forwards it to the field's content.
  //
  // When the content of the field changes, `mutableString()` converts
  // the string content to a number and forwards it to `n`
  content: n.mutableString();
)

Property Accessors for your own types:

With live_cell_extension you can generate cell accessors for your own classes:

@CellExtension(mutable: true)
class Person {
  final String firstName;
  final String lastName;
  
  Person({
    required this.firstName,
    required this.lastName
  });
}

You can now access firstName and lastName directly on cell's holding a Person:

final person = MutableCell(Person(...));

ValueCell.watch(() {
  print('${person.firstName()} ${person.LastName()}');
});

// This triggers the watch function defined above:
person.firstName.value = 'John';

Asynchronous Cells

Cells can hold and manipulate a Future like any other value:

ValueCell<Future<int>> cell1;
ValueCell<Future<int>> cell2;
...
final sum = ValueCell.computed(() async {
  final (a, b) = await (cell1(), cell2()).wait;
  return a + b;
});

Cells can await a Future held in another cell:

ValueCell<Future<int>> cell1;
ValueCell<Future<int>> cell2;
...
// The value of `sum` is only computed once the futures
// in both `cell1` and `cell2` have completed
final sum = ValueCell.computed(() {
  final (a, b) = (cell1, cell2).wait();
  return a + b;
});

Cells can also check if, and be notified when, a Future in another cell has completed:

ValueCell<Future<int>> cell1;
ValueCell<Future<int>> cell2;
...
// isLoading is true until both the Futures in cell1
// and cell2 have completed
final isLoading = (cell1, cell2).isCompleted.not();

ValueListenable (Integration with other tools)

The .listenable property returns a ValueListenable that notifies its observers whenever the value of the cell changes:

final count = MutableCell<int>(0);
final countListenable = count.listenable;

This allows cells to be used as a drop-in replacement for ValueNotifier, whenever a ValueListenable is expected:

ValueListenableBuilder(
  valueListenable: count.listenable,
  ...
)

Additional information

If you discover any issues or have any feature requests, please open an issue on the package's Github repository.

Please visit the full documentation for a full introduction to the library's features. Also take a look at the example directory for more complete examples of how to use this library.