Flutter Reactive

A lightweight reactive system for Flutter, inspired by simple state binding. No ChangeNotifier, no boilerplate — just Reactive values bound to States and optional streams.

Features

  • Reactive variables (Reactive<T>)
  • Automatic State updates when data changes
  • Manual listeners support
  • Reactive streams support (stream)
  • Extensions on common types (num, bool, List, String, Map, State)
  • No dependency on Flutter state management libraries

Installation

Import the package in your project:

import 'package:flutter_reactive/flutter_reactive.dart';

Basic Usage

Create a reactive value:

final counter = Reactive(0); // strict by default
final user = ReactiveN<UserModel>(); // nullable
final counterNotStrict = Reactive(0, false); // not strict, allows same value updates

or inside a State:

class _MyState extends State<MyWidget> {
    late final counter = react(0); // automatically binds to this State. Needs to be late.
    late final user = reactN<UserModel>(); // nullable type
    late final counterNotStrict = react(0, false); // not strict, allows same value updates
}

Read & write:

counter.value;      // get
counter.value = 1;  // set
counter.set(2);     // explicit

user.set(UserModel(...)); // set nullable value

counterNotStrict.value=1;
counterNotStrict.value=1; //still notifies because not strict

Update based on current value:

counter.update((v) => v + 1);
user.mutate((u) => u?.name = 'New Name'); // update and notify

Difference between set, update and mutate:

  • set(newValue): sets a new value and notifies listeners.
  • update((current) => newValue): computes a new value based on the current one and notifies listeners only if the new value is different from the current one (ex: changing user name but keeping the same UserModel instance).
  • mutate((current) => void): allows mutating the current value in place (useful for mutable objects). Always notifies listeners after mutation.

See the bests practices section for more details.

Binding a Reactive to a State

Bind a Reactive to a State so the UI updates automatically.


final counter = Reactive(0);// outside the State class
class _MyState extends State<MyWidget> {

    // or inside the State class
    // final counter = Reactive(0);

  @override
  void initState() {
    super.initState();
    counter.bind(this);
  }

  @override
  void dispose() {
    counter.unbind(this); // not strictly necessary, but cleaner
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Text(counter.value.toString());
  }
}

or using the react() helper:

class _MyState extends State<MyWidget> {
  late final counter = react(0); // automatically binds to this State. Needs to be late and inside the State class.

  @override
  Widget build(BuildContext context) {
    return Text(counter.value.toString());
  }
}

When counter.value changes, setState() is triggered internally.

Unbinding

You can manually unbind a state:

counter.unbind(this);

Note: Unmounted states are automatically cleaned up internally.

Listening without UI binding

Listen to value changes without binding to a State.

counter.listen((value) {
  print('Counter changed to $value');
});

Remove the listener:

counter.unlisten(myCallback);

Using Streams

Reactive variables expose a broadcast stream:

counter.stream.listen((value) {
    print("New value: $value");
});

// Difference between listen() and stream.listen():
counter.listen((value) {
    print("Listener: $value");
});

counter.value++;
// triggers both stream listener and listen() callback but stream can be used inside a StreamBuilder

StreamBuilder<int>(
    stream: counter.stream,
    builder: (context, snapshot) {
        return Text('Counter: ${snapshot.data}');
    },
);

Using debounce with Reactive

You can debounce updates to avoid too many notifications in a short time.Also useful for search inputs and forms.

final counter = Reactive(0);
counter.debounce(Duration(seconds: 3).inMilliseconds, (value) {
  print("Counter $value");
});

Combine many Reactives

You can combine multiple Reactive<T> into one Reactive<R>.

final a = Reactive(1);
final b = Reactive(2);
final sum = Reactive.combine([a, b], (values) => values[0] + values[1]);
// or sum = Reactive.combine2(a, b, (aVal, bVal) => aVal + bVal);
sum.listen((value) {
    print('Sum changed to $value');
});
a.value = 3; // sum updates to 5
b.value = 4; // sum updates to 7

final active=true.reactive();
final count=0.reactive();
final message=''.reactive();
final status = Reactive.combine3(
    active, count, message,
    (isActive, cnt, msg) {
        return 'Status: ${isActive ? "Active" : "Inactive"}, Count: $cnt, Message: $msg';
    },
);
status.listen((value) {
    print(value);
});

active.disable(); // prints: Status: Inactive, Count: 0, Message:
count.value = 10; // prints: Status: Inactive, Count: 10, Message:
message.value = 'Hello'; // prints: Status: Inactive, Count: 10, Message: Hello
active.toggle(); // prints: Status: Active, Count: 10, Message: Hello

There are combine methods for up to 5 Reactives (combine2, combine3, combine4, combine5).
For more, use combine() with a list.

If no combination function is required, use Reactive.computed():

final a = Reactive(1);
final b = Reactive(2);
final isVisible = Reactive(true);
final combined = Reactive.computed([a, b, isVisible]); // no function needed, just tracks changes
combined.listen((values) {
    print('Values changed: $values');
});

Dispose the Reactive

If you want to clean up all bindings and listeners:

counter.dispose();

This will unbind all States, remove all listeners and close the stream.

How it works

  • Reactive<T> stores a value
  • Keeps a list of bound States and listeners
  • On update:
    • unmounted states are removed
    • active states are updated
    • listeners are notified
  • Each update also emits a value to the broadcast stream

Simple, explicit, predictable.

Extensions

This package exposes extensions on:

  • State (updateState, react(), reactN())
  • num
  • bool
  • List
  • String
  • Map

Example:

final isVisible = true.reactive();
final count = 0.reactive();
final items = <String>[].reactive();

isVisible.toggle(); // flips the boolean
count.increment(); // adds 1
count.decrement(); // subtracts 1
items.addToSet('item'); // adds if not present
items.remove('item'); // removes if present

Reactive API

Constructor:

Reactive(T initialValue)
ReactiveN<T>() // nullable
Reactive(T initialValue, bool isStrict) // isStrict: prevents notifying on same value updates. Default: true

Properties:

  • value
  • stream

Methods:

  • set(T newValue)
  • update(T Function(T current))
  • bind(State state)
  • unbind(State state)
  • listen(void Function(T) callback)
  • unlisten(void Function(T) callback)
  • notify()
  • dispose()
  • mutate(void Function(T) mutator)
  • debounce(int milliseconds, void Function(T) callback)

Static Methods:

  • combine(List<Reactive> reactives, R Function(List<dynamic>) combiner)
  • combine2, combine3, combine4, combine5
  • computed(List<Reactive> reactives, [R Function(List<dynamic>)? combiner])

Widgets:

  • ReactiveBuilder<T>
final counter= 0.reactive();
ReactiveBuilder(
    reactive: counter,
    builder: (value) => Text('Counter: $value'),
);
  • ReactiveStreamBuilder<T>
ReactiveStreamBuilder(
    reactive: counter,
    builder: (context, snapshot) {
      if(!snapshot.hasData) {
        return CircularProgressIndicator();
      }
      return Text('Counter: ${snapshot.data}');
    },
);

Difference between ReactiveBuilder and ReactiveStreamBuilder

  • ReactiveBuilder rebuilds when the Reactive value changes, using internal binding to State.
  • ReactiveStreamBuilder rebuilds based on the Reactive's stream, useful for integrating with other stream-based widgets. Therefore, no internal State binding is done and you can access to the snapshot.

Tips and Best Practices

  • Avoid in-place mutations without notify():
final user = ReactiveN<UserModel>();
user.value = user.value.copyWith(name: "Max") // correct
user.mutate((u) { u?.name = "Max"; }) // correct

user.value.name = "Max" // incorrect, change is done but needs manually notify()
user.update((u) { u?.name = "Max"; return u; }) // incorrect, change is done but will not notify cause same instance and isStrict = true
  • Use isStrict = false if you want to allow same value updates:
final user= ReactiveN<UserModel>(null, false); // not strict
user.update((u) {
    u?.name = "Max";
    return u; // will notify even if same instance
});
  • Use debounce() for search inputs or frequent updates:
final searchQuery = ''.reactive();

searchQuery.listen((value) {
  search(value);  // ❌ Bad practice: this will trigger on every keystroke
});
searchQuery.debounce(500, (value) {
  search(value);  // ✅ Good practice: this will trigger only after 500ms of inactivity
});
  • Use combine() or computed() to track multiple Reactives:
final a = Reactive(1);
final b = Reactive(2);

// ❌ BAD
final sum = Reactive(0);
a.listen((aVal) {
    sum.value = aVal + b.value;
});
b.listen((bVal) {
    sum.value = a.value + bVal;
});

// ✅ GOOD
final sum = Reactive.combine2(a, b, (aVal, bVal) => aVal + bVal);
//or int get sum => a.value + b.value;
final combined = Reactive.computed([a, b], ()=> a.value + b.value);
  • Don't manually change combined or computed Reactives:

Combined or computed Reactives should not be set manually as they derive their value from other Reactives. Because it is possible do not mean to do it, avoid it to prevent confusion and unpredictable behavior.

final a = Reactive(1);
final b = Reactive(2);
final sum = Reactive.combine2(a, b, (aVal, bVal) => aVal + bVal);
sum.value = 10; // ❌ BAD: sum is computed, don't set it manually
  • Limit excessive rebuilds:

Using react() or Reactive<T>.bind() inside a State class is the most common use case but should be used wisely cause each change triggers a setState().
If you have many Reactive values changing frequently, or all your state does not depend on them, consider using Reactive<T> + ReactiveBuilder or ReactiveStreamBuilder to limit rebuilds to only the widgets that need them.
Here are some examples:

class CounterWidget extends StatefulWidget {
  @override
  _CounterWidgetState createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
  late final counter = react(0); // binds to this State
  late final counterNotBound = Reactive(0); // can be outside the State class

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Counter: $counter'), //the state rebuilds on counter change
        ReactiveBuilder(
          reactive: counterNotBound, // only this widget rebuilds on counterNotBound change
          builder: (value) => Text('Counter: $value'),
        ),
        ElevatedButton(
          onPressed: () => counter.increment(), // excessive rebuilds
          child: Text('Increment'),
        ),
        ElevatedButton(
          onPressed: () => counterNotBound.increment(), // only rebuilds ReactiveBuilder
          child: Text('Increment Not Bound'),
        ),
      ],
    );
  }
}

For larger applications, consider separating your state management from your UI components. Use Reactive variables in your store or controller classes, and bind them to your UI using ReactiveBuilder or ReactiveStreamBuilder. This approach promotes a cleaner architecture and better separation of concerns.

// lib/stores/auth_store.dart
import 'package:flutter_reactive/flutter_reactive.dart';
class AuthStore {
  static final user = ReactiveN<UserModel>(); // static so can be used globally if needed

  static bool get isLoggedIn => user.value != null;

  static void login(UserModel newUser) {
    user.value = newUser;
  }

  static void logout() {
    user.value = null;
  }
}
// lib/stores/form_store.dart
import 'package:flutter_reactive/flutter_reactive.dart';
class FormStore {
  final username = ''.reactive(); // or Reactive("");
  final password = ''.reactive();

  final isValid = Reactive.combine2(
    username,
    password,
    (u, p) => u.isNotEmpty && p.isNotEmpty && p.length >= 6,
  );
  
  
  void updateUsername(String value) {
    username.value = value;
  }
  
  void updatePassword(String value) {
    password.value = value;
  }

  void save()async {
    if(isValid.isTrue){
      final json = await db.login(username.value, password.value);
      AuthStore.login(UserModel.fromJson(json)); // save user globally
    }
  }
  
}
// lib/widgets/login_form.dart
import 'package:flutter/material.dart';
import 'package:flutter_reactive/flutter_reactive.dart';
import '../stores/form_store.dart';
class LoginForm extends StatelessWidget {
  final FormStore store = FormStore();

  void initState() {
    AuthStore.user.bind(this); // bind to AuthStore user to update UI on login/logout
  }

  @override
  Widget build(BuildContext context) {
    return AuthStore.isLoggedIn
      ? Column(
          children: [
            Text('Welcome, ${AuthStore.user.value?.name}!'),
            ElevatedButton(
              onPressed: () => AuthStore.logout(),
              child: Text('Logout'),
            ),
          ],
        )
      : Column(
          children: [
            TextField(
              onChanged: store.updateUsername,
              decoration: InputDecoration(labelText: 'Username'),
            ),
        TextField(
          onChanged: store.updatePassword,
          decoration: InputDecoration(labelText: 'Password'),
          obscureText: true,
        ),
        ReactiveBuilder(
          reactive: store.isValid,
          builder: (isValid) {
            return ElevatedButton(
              onPressed: isValid ? store.save : null,
              child: Text('Login'),
            );
          },
        ),
      ],
    );
  }
}

In this example, the AuthStore manages the global user state, while the FormStore handles the login form state. The LoginForm widget binds to the AuthStore to update the UI based on the authentication state.

License

This project is licensed under the MIT License - see the LICENSE file for details. Do whatever you want but don't blame the code ;).