Reactr

Pub Version License

Reactr is a powerful and lightweight state management library for Flutter applications. It provides a simple, efficient, and reactive way to manage application state using controllers, bindings, and reactive data types. Built with performance and developer experience in mind, Reactr offers automatic dependency tracking, lifecycle management, and seamless integration with Flutter's widget system.

✨ Features

  • 🔄 Reactive State Management: Automatic dependency tracking and UI updates
  • 🎯 Controller-Based Architecture: Clean separation of business logic and UI
  • 🔗 Dependency Injection: Built-in service locator with singleton and lazy singleton support
  • 🧭 Navigation Management: Simplified navigation with automatic controller lifecycle management
  • 💾 Persistent Storage: Synchronous storage wrapper with SharedPreferences
  • 🎨 UI Utilities: SnackBars, BottomSheets, and other UI helpers
  • ⚡ Performance Optimized: Efficient reactive updates with minimal rebuilds
  • 🛠️ Animation Support: Built-in ticker providers for animations
  • 📱 Flutter Material App: Drop-in replacement with enhanced features

📦 Installation

Add Reactr to your pubspec.yaml:

dependencies:
  reactr: ^0.2.9

Then run:

flutter pub get

🚀 Quick Start

1. Setup Your App

Replace your MaterialApp with ReactrMaterialApp:

import 'package:flutter/material.dart';
import 'package:reactr/reactr.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return ReactrMaterialApp(
      title: 'Reactr Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const HomePage(),
    );
  }
}

2. Create a Controller

import 'package:reactr/reactr.dart';

class CounterController extends ReactrController {
  // Reactive variables
  final count = RcInt(0);
  final name = RcString('Reactr');
  final isVisible = RcBool(true);
  
  // Nullable reactive variables
  final optionalValue = RcnString(null);

  @override
  void onInit() {
    super.onInit();
    // Initialize your controller
    print('CounterController initialized');
  }

  void increment() {
    count.value++;
  }

  void decrement() {
    count.value--;
  }

  void toggleVisibility() {
    isVisible.value = !isVisible.value;
  }

  @override
  void onClose() {
    super.onClose();
    // Clean up resources
    print('CounterController disposed');
  }
}

3. Create a Binding

import 'package:reactr/reactr.dart';
import 'counter_controller.dart';

class CounterBinding extends ReactrBinding {
  @override
  void onBind() {
    // Register controller when route is pushed
    Reactr.putLazySingleton(() => CounterController());
  }

  @override
  void unBind() {
    // Clean up controller when route is popped
    Reactr.remove<CounterController>();
  }
}

4. Create a View

import 'package:flutter/material.dart';
import 'package:reactr/reactr.dart';
import 'counter_controller.dart';

class CounterView extends ReactrView<CounterController> {
  const CounterView({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Counter Demo'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // Reactive widget that rebuilds when count changes
            React(
              () => Text(
                'Count: ${controller.count.value}',
                style: Theme.of(context).textTheme.headlineMedium,
              ),
            ),
            const SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: controller.decrement,
                  child: const Icon(Icons.remove),
                ),
                const SizedBox(width: 20),
                ElevatedButton(
                  onPressed: controller.increment,
                  child: const Icon(Icons.add),
                ),
              ],
            ),
            const SizedBox(height: 20),
            // Multi-reactive widget
            MultiReact(
              values: [controller.count, controller.isVisible],
              builder: (values) {
                final count = values[0] as int;
                final isVisible = values[1] as bool;
                return isVisible 
                  ? Text('Visible count: $count')
                  : const SizedBox.shrink();
              },
            ),
          ],
        ),
      ),
    );
  }
}

5. Navigation

// Navigate to a new screen
ElevatedButton(
  onPressed: () => Reactr.to(
    binding: CounterBinding(),
    builder: () => const CounterView(),
  ),
  child: const Text('Open Counter'),
),

// Navigate with named route
ElevatedButton(
  onPressed: () => Reactr.toNamed(
    binding: CounterBinding(),
    routeName: '/counter',
  ),
  child: const Text('Open Counter (Named)'),
),

// Replace current screen
Reactr.replace(
  binding: CounterBinding(),
  builder: () => const CounterView(),
),

// Navigate and clear stack
Reactr.replaceUntil(
  binding: CounterBinding(),
  builder: () => const CounterView(),
  predicate: (route) => false, // Clear all routes
),

📚 Core Concepts

Reactive Data Types

Reactr provides several reactive data types that automatically trigger UI updates when their values change:

Basic Types

  • Rc<T> - Generic reactive container
  • RcInt - Reactive integer
  • RcDouble - Reactive double
  • RcString - Reactive string
  • RcBool - Reactive boolean
  • RcList<T> - Reactive list
  • RcMap<K, V> - Reactive map

Nullable Types

  • Rcn<T> - Generic nullable reactive container
  • RcnInt - Nullable reactive integer
  • RcnDouble - Nullable reactive double
  • RcnString - Nullable reactive string
  • RcnBool - Nullable reactive boolean
class MyController extends ReactrController {
  final count = RcInt(0);
  final name = RcString('Hello');
  final items = RcList<String>(['item1', 'item2']);
  final settings = RcMap<String, dynamic>({'theme': 'dark'});
  final optionalValue = RcnString(null);
  
  void updateData() {
    count.value++;
    name.value = 'Updated';
    items.value.add('item3');
    settings.value['theme'] = 'light';
    optionalValue.value = 'Now has value';
  }
}

Reactive Widgets

React Widget

The React widget automatically rebuilds when any reactive variable used within it changes:

React(
  () => Text('Count: ${controller.count.value}'),
)

MultiReact Widget

The MultiReact widget can listen to multiple reactive variables:

MultiReact(
  values: [controller.count, controller.name, controller.isVisible],
  builder: (values) {
    final count = values[0] as int;
    final name = values[1] as String;
    final isVisible = values[2] as bool;
    
    return isVisible 
      ? Text('$name: $count')
      : const SizedBox.shrink();
  },
)

Controller Lifecycle

Controllers have three lifecycle methods:

class MyController extends ReactrController {
  @override
  void onInit() {
    super.onInit();
    // Called when controller is created
    // Initialize variables, load data, etc.
  }

  @override
  void onReady() {
    super.onReady();
    // Called after the first frame is rendered
    // Good place for post-frame initialization
  }

  @override
  void onClose() {
    super.onClose();
    // Called when controller is disposed
    // Clean up resources, cancel subscriptions, etc.
  }
}

Dependency Injection

Reactr provides a simple dependency injection system:

// Register a singleton
Reactr.putSingleton(MyController());

// Register a lazy singleton
Reactr.putLazySingleton(() => MyController());

// Check if registered
if (Reactr.contains<MyController>()) {
  // Controller is registered
}

// Get controller instance
final controller = Reactr.find<MyController>();

// Remove controller
Reactr.remove<MyController>();

🎨 UI Utilities

SnackBars

Reactr.snackBar(
  content: const Text('This is a snackbar'),
  backgroundColor: Colors.green,
  duration: const Duration(seconds: 3),
  action: SnackBarAction(
    label: 'Undo',
    onPressed: () => print('Undo pressed'),
  ),
);

Bottom Sheets

Reactr.bottomSheet(
  builder: (context) => Container(
    height: 200,
    child: const Center(
      child: Text('This is a bottom sheet'),
    ),
  ),
  isScrollControlled: true,
  backgroundColor: Colors.white,
);

Context and Media Queries

// Get context
final context = Reactr.context;

// Check if dark mode
if (Reactr.isDarkMode) {
  // Dark mode is active
}

// Get screen dimensions
final width = Reactr.width;
final height = Reactr.height;

// Get navigation arguments
final args = Reactr.arguments;

💾 Storage

ReactrStorage provides a synchronous wrapper around SharedPreferences:

class UserController extends ReactrController {
  final username = RcString('');
  
  @override
  void onInit() {
    super.onInit();
    // Load saved username
    username.value = ReactrStorage.getString('username') ?? '';
  }
  
  void saveUsername(String name) {
    username.value = name;
    ReactrStorage.setString('username', name);
  }
  
  void clearData() {
    ReactrStorage.remove('username');
    username.value = '';
  }
}

Storage Methods

// String operations
ReactrStorage.setString('key', 'value');
String? value = ReactrStorage.getString('key');

// Number operations
ReactrStorage.setInt('count', 42);
int? count = ReactrStorage.getInt('count');

ReactrStorage.setDouble('price', 19.99);
double? price = ReactrStorage.getDouble('price');

// Boolean operations
ReactrStorage.setBool('isLoggedIn', true);
bool? isLoggedIn = ReactrStorage.getBool('isLoggedIn');

// List operations
ReactrStorage.setStringList('tags', ['flutter', 'dart']);
List<String>? tags = ReactrStorage.getStringList('tags');

// Utility methods
ReactrStorage.remove('key');
bool hasKey = ReactrStorage.containsKey('key');
Set<String> keys = ReactrStorage.getKeys();
ReactrStorage.clear();

🎭 Animations

Reactr provides mixins for animation support in controllers:

Single Animation Controller

class AnimatedController extends ReactrController 
    with ReactrSingleTickerProviderStateMixin {
  
  late AnimationController animationController;
  late Animation<double> animation;
  
  @override
  void onInit() {
    super.onInit();
    animationController = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 1),
    );
    animation = Tween<double>(begin: 0, end: 1).animate(animationController);
  }
  
  void startAnimation() {
    animationController.forward();
  }
  
  @override
  void onClose() {
    animationController.dispose();
    super.onClose();
  }
}

Multiple Animation Controllers

class MultiAnimatedController extends ReactrController 
    with ReactrTickerProviderStateMixin {
  
  late AnimationController fadeController;
  late AnimationController slideController;
  
  @override
  void onInit() {
    super.onInit();
    fadeController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 500),
    );
    slideController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
  }
  
  @override
  void onClose() {
    fadeController.dispose();
    slideController.dispose();
    super.onClose();
  }
}

🔧 Advanced Usage

Custom Reactive Types

You can create custom reactive types by extending Rc:

class RcUser extends Rc<User> {
  RcUser(super.value);
  
  void updateName(String name) {
    value = value.copyWith(name: name);
  }
  
  bool get isAdult => value.age >= 18;
}

Route Arguments

Pass data between screens:

// Navigate with arguments
Reactr.to(
  binding: UserBinding(),
  builder: () => const UserView(),
  arguments: {'userId': 123, 'action': 'edit'},
);

// Access arguments in controller
class UserController extends ReactrController {
  @override
  void onInit() {
    super.onInit();
    final args = Reactr.arguments as Map<String, dynamic>?;
    final userId = args?['userId'] as int?;
    if (userId != null) {
      loadUser(userId);
    }
  }
}

Global State Management

For global state that persists across routes:

class GlobalController extends ReactrController {
  final currentUser = Rc<User?>(null);
  final theme = RcString('light');
  
  void setTheme(String newTheme) {
    theme.value = newTheme;
    ReactrStorage.setString('theme', newTheme);
  }
}

// Register globally in main()
void main() {
  Reactr.putSingleton(GlobalController());
  runApp(const MyApp());
}

📱 Example App

Check out the complete example in the example/ directory:

cd example
flutter run

The example demonstrates:

  • Basic counter functionality
  • Navigation between screens
  • SnackBars and BottomSheets
  • Multiple reactive variables
  • Controller lifecycle management

🤝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

📄 License

This project is licensed under the MIT License - see the LICENSE file for details.


Made with ❤️ for the Flutter community