Redus Flutter

Vue-like ReactiveWidget and web-style routing for Flutter with fine-grained reactivity, lifecycle hooks, and dependency injection.

Flutter pub package

Features

  • 🎯 ReactiveWidget - Reactive widget with auto-reactivity in render() (supports Flutter mixins)
  • πŸ›€οΈ Redus Router - Web-style routing with useRoute() / useRouter() (context-free)
  • πŸ‘οΈ Observe - Widget that watches a source and rebuilds
  • ⚑ ObserveEffect - Widget that auto-tracks dependencies
  • πŸ”„ Lifecycle Hooks - onInitState, onMounted, onDispose, etc.
  • 🧩 Composable Mixins - LifecycleCallbacks for custom widgets
  • πŸ’‰ Dependency Injection - Type + key-based lookup (from redus)
  • 🧹 Auto Cleanup - Effect scopes tied to widget lifecycle
  • πŸ”— Deep Linking - Built-in support with app_links

Installation

dependencies:
  redus_flutter: ^0.12.0

Quick Start

ReactiveWidget

Full reactivity with automatic dependency tracking in render():

import 'package:redus_flutter/redus_flutter.dart';

class CounterStore {
  final count = ref(0);
  void increment() => count.value++;
}

class Counter extends ReactiveWidget {
  const Counter({super.key});

  @override
  ReactiveState<Counter> createState() => _CounterState();
}

class _CounterState extends ReactiveState<Counter> {
  late final CounterStore store;

  @override
  void setup() {
    store = CounterStore();
    onMounted(() => print('Count: ${store.count.value}'));
    onDispose(() => print('Cleaning up...'));
  }

  @override
  Widget render(BuildContext context) {
    // Auto-tracks store.count.value - rebuilds automatically
    return ElevatedButton(
      onPressed: store.increment,
      child: Text('Count: ${store.count.value}'),
    );
  }
}

ReactiveWidget with Flutter Mixins

When you need Flutter's built-in State mixins (for animations, keep-alive, etc.):

class AnimatedCounter extends ReactiveWidget {
  const AnimatedCounter({super.key});

  @override
  ReactiveState<AnimatedCounter> createState() => _AnimatedCounterState();
}

class _AnimatedCounterState extends ReactiveState<AnimatedCounter>
    with SingleTickerProviderStateMixin {
  late final AnimationController controller;
  late final Ref<int> count;

  @override
  void setup() {
    controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
    count = ref(0);
    onMounted(() => controller.forward());
    onDispose(() => controller.dispose());
  }

  @override
  Widget render(BuildContext context) {
    return FadeTransition(
      opacity: controller,
      child: ElevatedButton(
        onPressed: () => count.value++,
        child: Text('Count: ${count.value}'),
      ),
    );
  }
}

Using with AutomaticKeepAliveClientMixin

Some Flutter mixins require overriding build(). Use reactiveBuild() to get full reactive functionality:

class _KeepAliveState extends ReactiveState<KeepAliveWidget>
    with AutomaticKeepAliveClientMixin {
  late final Ref<int> count;

  @override
  bool get wantKeepAlive => true;

  @override
  void setup() {
    count = ref(0);
  }

  @override
  Widget build(BuildContext context) {
    super.build(context); // Required for the mixin
    return reactiveBuild(context); // Full reactive functionality!
  }

  @override
  Widget render(BuildContext context) {
    return Text('Count: ${count.value}');
  }
}

Observe Widget

Observe watches a reactive source (like watch()) and rebuilds when it changes:

final count = ref(0);

// Watch a Ref directly
Observe<int>(
  source: count,
  builder: (context, value) => Text('Count: $value'),
)

// Watch a derived value
Observe<int>(
  source: () => count.value * 2,
  builder: (context, doubled) => Text('Doubled: $doubled'),
)

// Watch multiple sources
ObserveMultiple<String>(
  sources: [firstName, lastName],
  builder: (context, values) => Text('${values[0]} ${values[1]}'),
)

ObserveEffect Widget

ObserveEffect auto-tracks any reactive values (like watchEffect()):

final count = ref(0);
final name = ref('Alice');

// Auto-tracks all .value accesses
ObserveEffect(
  builder: (context) => Column(
    children: [
      Text('Count: ${count.value}'),
      Text('Name: ${name.value}'),
    ],
  ),
)

Fine-Grained .watch(context)

Use .watch(context) in any widget for automatic rebuilds:

class MyStatelessWidget extends StatelessWidget {
  final Ref<int> count;
  
  @override
  Widget build(BuildContext context) {
    // Only THIS widget rebuilds when count changes
    return Text('Count: ${count.watch(context)}');
  }
}

Redus Router

Web-framework-style routing with context-free hooks:

import 'package:redus_flutter/router.dart';

// Define routes
final routes = [
  RouteConfig(path: '/', builder: (_) => HomePage()),
  RouteConfig(
    path: '/users/:id',
    builder: (_) => UserPage(),
    transition: RouteTransition.fade(),
    guards: [AuthGuard()],
    meta: {'requiresAuth': true},
  ),
  RouteConfig(path: '*', builder: (_) => NotFoundPage()),
];

// Use in app
void main() {
  runApp(
    MaterialApp.router(
      routerConfig: RedusRouterConfig(
        routes: routes,
        initialRoute: '/',
        beforeEach: (to, from) async {
          if (to.meta['requiresAuth'] == true && !isLoggedIn) {
            return '/login';
          }
          return null;
        },
      ),
    ),
  );
}

// Access route anywhere (no context needed!)
final route = useRoute();
print(route.params['id']);
print(route.query['tab']);

// Navigate anywhere
useRouter().push('/users/123');
useRouter().back();

Router Config Options

RedusRouterConfig supports many configuration options:

RedusRouterConfig(
  routes: routes,
  initialRoute: '/',
  
  // Global default transition for all routes
  defaultTransition: RouteTransition.fade(),
  
  // Wrap all routes in a common layout
  layoutBuilder: (context, child) => Scaffold(
    appBar: AppBar(title: Text('My App')),
    drawer: AppDrawer(),
    body: child,
  ),
  
  // Custom 404 page
  notFoundBuilder: (location) => NotFoundPage(path: location.path),
  
  // Navigation guards
  beforeEach: (to, from) async {
    if (to.meta['auth'] == true && !isLoggedIn) return '/login';
    return null;
  },
  afterEach: (to, from) async => analytics.track('page_view', to.path),
  
  // Navigator observers (for analytics, logging)
  observers: [AnalyticsObserver(), LoggingObserver()],
  
  // Redirect initial route based on state
  onInitialRoute: (route) {
    if (!onboardingComplete) return '/onboarding';
    if (!isLoggedIn) return '/login';
    return route;
  },
)
Option Type Description
routes List<RouteConfig> Route definitions (required)
initialRoute String Starting route path (default: /)
defaultTransition RouteTransition? Global page transition
layoutBuilder RouteLayoutBuilder? Wraps all routes in layout
notFoundBuilder NotFoundBuilder? Custom 404 page
beforeEach SimpleGuard? Guard before each navigation
afterEach SimpleGuard? Hook after each navigation
observers List<NavigatorObserver>? Navigator observers
onInitialRoute InitialRouteRedirect? Redirect initial route

Route Transitions

RouteConfig(
  path: '/page',
  builder: (_) => Page(),
  transition: RouteTransition.fade(),           // Fade
  transition: RouteTransition.slide(direction: SlideDirection.up),  // Slide
  transition: RouteTransition.scale(),          // Scale
  transition: RouteTransition.none,             // No animation
  transition: RouteTransition.custom(           // Custom
    transitionsBuilder: (ctx, anim, secAnim, child) => ...,
  ),
)

Route Guards

class AuthGuard extends RouteGuard {
  @override
  Future<GuardResult> canActivate(RouteLocation to, RouteLocation from) async {
    if (!isLoggedIn) {
      return GuardRedirect('/login?redirect=${to.fullPath}');
    }
    return const GuardAllow();
  }
  
  @override
  Future<bool> canDeactivate(RouteLocation to, RouteLocation from) async {
    // Return false to block leaving (e.g., unsaved changes)
    return true;
  }
  
  @override
  Future<Map<String, dynamic>?> resolve(RouteLocation to) async {
    // Prefetch data before route activates
    return {'user': await fetchUser(to.params['id']!)};
  }
}

Deep Linking

Deep linking is powered by the app_links package. You need to configure your platform projects to handle deep links.

Platform Configuration

Android (android/app/src/main/AndroidManifest.xml):

<intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https" android:host="yourdomain.com" />
    <data android:scheme="myapp" android:host="open" />
</intent-filter>

iOS (ios/Runner/Info.plist):

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>myapp</string>
        </array>
    </dict>
</array>
<key>FlutterDeepLinkingEnabled</key>
<true/>

Web (web/index.html - for path-based routing):

<base href="/">

For full setup instructions including Universal Links (iOS) and App Links (Android), see the app_links documentation.

Usage

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // Get initial link if app started via deep link
  final initialLink = await DeepLinkHandler.getInitialLink();
  
  runApp(
    MaterialApp.router(
      routerConfig: RedusRouterConfig(
        routes: routes,
        initialRoute: initialLink ?? '/',
      ),
    ),
  );
  
  // Listen for incoming links while app is running
  DeepLinkHandler.listenToIncomingLinks();
}

Lifecycle Hooks

Hook When Default Timing
onInitState During initState after
onMounted After first frame -
onDidChangeDependencies When InheritedWidget deps change after
onDidUpdateWidget When widget props change after
onDeactivate Widget removed from tree after
onActivate Widget reinserted into tree after
onDispose Widget disposed before
onErrorCaptured Error boundary -

Timing Control

All hooks support timing parameter for before/after control:

@override
void setup() {
  // Fire before super.initState()
  onInitState(() => print('before'), timing: LifecycleTiming.before);
  
  // Fire after super.initState() (default)
  onInitState(() => print('after'));
  
  // Fire before dispose (default for onDispose)
  onDispose(() => cleanup(), timing: LifecycleTiming.before);
  
  // Access old and new widget
  onDidUpdateWidget<MyWidget>((oldWidget, newWidget) {
    if (oldWidget.value != newWidget.value) {
      print('Value changed!');
    }
  });
}

Composable Architecture

The library uses a composable mixin architecture:

// ReactiveWidget uses State-based mixins internally
class ReactiveState extends State<ReactiveWidget>
    with LifecycleCallbacks, LifecycleHooksStateMixin, 
         ReactiveStateMixin { ... }

// Use mixins in your own StatefulWidget
class _MyWidgetState extends State<MyWidget>
    with LifecycleCallbacks, LifecycleHooksStateMixin {
  
  late final MyStore store;
  
  @override
  void initState() {
    store = MyStore();
    onMounted(() => print('Mounted!'));
    onDispose(() => print('Disposing...'));
    super.initState();
  }
  
  @override
  Widget build(BuildContext context) {
    scheduleMountedCallbackIfNeeded();
    return Text('Value: ${store.value}');
  }
}

Available mixins:

  • LifecycleCallbacks - Callback storage and registration
  • LifecycleHooksStateMixin - Flutter lifecycle method overrides
  • ReactiveStateMixin - EffectScope and reactivity for auto-tracking

Dependency Injection

DI comes from redus package with key support:

// By type
register<ApiService>(ApiService());
final api = get<ApiService>();

// By key (multiple instances)
register<Logger>(ConsoleLogger(), key: #console);
register<Logger>(FileLogger(), key: #file);
final log = get<Logger>(key: #console);

When to Use What

Widget Use When
ReactiveWidget Full component with auto-reactivity, lifecycle, stores
Observe<T> Watch specific source(s), explicit dependency
ObserveEffect Auto-track multiple dependencies in builder
.watch(context) Simple inline reactive values in any widget

License

MIT License

Libraries

deep_linking
Deep linking support for Redus Router.
reactivity
Redus Flutter Reactivity - Vue-like reactive widgets and lifecycle hooks.
redus_flutter
Vue-like Component system for Flutter with reactive state and lifecycle hooks.
router
Redus Router - Web-framework-style reactive routing for Flutter.