redus_flutter 0.12.0
redus_flutter: ^0.12.0 copied to clipboard
Vue-like reactivity for Flutter with ref(), computed(), watch(), lifecycle hooks, and web-style routing.
Redus Flutter #
Vue-like ReactiveWidget and web-style routing for Flutter with fine-grained reactivity, lifecycle hooks, and dependency injection.
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 registrationLifecycleHooksStateMixin- Flutter lifecycle method overridesReactiveStateMixin- 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