neuron 1.2.0
neuron: ^1.2.0 copied to clipboard
Signal/Slot reactive state management for Flutter. Clean syntax, powerful features including middleware, persistence, time-travel debugging, and code generation support.
Neuron #
Signal/Slot Reactive State Management for Flutter
Neuron is a powerful, elegant reactive state management solution built around the Signal/Slot pattern. Designed for simplicity, performance, and exceptional developer experience.
🤔 Why Neuron? #
Neuron's Philosophy #
"Write less, do more, stay reactive."
Neuron brings the battle-tested Signal/Slot pattern from Qt to Flutter. This pattern has powered desktop applications for 30+ years because it's:
- Intuitive: Signals emit values, Slots receive them
- Decoupled: Signals don't know about Slots, and vice versa
- Efficient: Only connected components update
- Predictable: Data flows in one direction
Key Benefits #
| Feature | Neuron |
|---|---|
| Boilerplate | Minimal |
| Learning curve | Gentle |
| Type safety | Excellent |
| Fine-grained rebuilds | Yes |
| Context dependency | No |
| Memory management | Automatic |
| Async handling | Built-in |
| Animations | Built-in |
🧠 Understanding Signals & Slots #
What is a Signal? #
A Signal is a reactive container that holds a value and notifies listeners when it changes. Think of it as a "smart variable" that broadcasts its changes.
// Create a Signal
final count = Signal<int>(0);
// Read the value
print(count.val); // 0
// Update the value (notifies all listeners)
count.emit(5); // All listeners receive 5
// Listen to changes
count.addListener(() => print('Changed to: ${count.val}'));
What is a Slot? #
A Slot is a widget that "plugs into" a Signal and automatically rebuilds when the Signal changes. It's the bridge between your reactive data and the UI.
Slot<int>(
connect: count, // Plug into the Signal
to: (context, value) => Text('Count: $value'), // Build UI
)
The Connection #
┌─────────────────┐ ┌─────────────────┐
│ SIGNAL │ │ SLOT │
│ │ connect │ │
│ count.emit(5) │ ───────►│ Text('5') │
│ │ │ rebuilds │
└─────────────────┘ └─────────────────┘
When you call count.emit(5), only the Slot widgets connected to count rebuild—not the entire widget tree. This is fine-grained reactivity.
✨ Why Developers Love Neuron #
1. Zero Boilerplate #
// controller.dart
class CounterController extends NeuronController {
// Choose your style:
late final count = Signal<int>(0).bind(this); // Explicit
late final count = signal(0); // Clean ✨
late final count = $(0); // Ultra-short
void increment() => count.emit(count.val + 1);
void decrement() => count.emit(count.val - 1);
static CounterController get init => Neuron.ensure(() => CounterController());
}
// widget.dart
Slot<int>(
connect: CounterController.init.count,
to: (_, val) => Text('$val'),
)
Result: Clean, readable, minimal code.
2. No BuildContext Required #
Access your controllers from anywhere—services, utils, or other controllers:
// In a service
class AnalyticsService {
void trackClick() {
final count = CounterController.init.count.val;
analytics.log('button_click', {'count': count});
}
}
// In another controller
class DashboardController extends NeuronController {
void syncData() {
final userCount = UserController.init.users.val.length;
final orderCount = OrderController.init.orders.val.length;
// No context needed!
}
}
3. Built-in Async Handling #
No more juggling loading states and error handling:
class UserController extends NeuronController {
late final user = asyncSignal<User>();
Future<void> loadUser(String id) async {
await user.execute(() => api.fetchUser(id));
}
static UserController get init => Neuron.ensure(() => UserController());
}
// In UI - handles loading, error, and data states automatically
AsyncSlot<User>(
connect: UserController.init.user,
onLoading: (_) => CircularProgressIndicator(),
onError: (_, error) => Text('Error: $error'),
onData: (_, user) => UserCard(user),
)
4. Automatic Computed Values #
Derived values that automatically update when dependencies change:
class CartController extends NeuronController {
late final items = signal<List<CartItem>>([]);
late final discount = signal<double>(0.0);
// Automatically recalculates when items or discount changes!
late final total = computed(() {
final subtotal = items.val.fold(0.0, (sum, item) => sum + item.price);
return subtotal * (1 - discount.val);
});
static CartController get init => Neuron.ensure(() => CartController());
}
5. Beautiful Animations Out of the Box #
AnimatedSlot<int>(
connect: c.count,
effect: SlotEffect.scale | SlotEffect.fade,
curve: Curves.elasticOut,
to: (_, value) => Text('$value', style: TextStyle(fontSize: 48)),
)
🚀 Quick Start #
Installation #
flutter pub add neuron
Step 1: Create a Controller #
import 'package:neuron/neuron.dart';
class CounterController extends NeuronController {
// Option 1: Verbose (explicit)
late final count = Signal<int>(0).bind(this);
// Option 2: Clean (recommended)
late final count = signal(0);
// Option 3: Ultra-short
late final count = $(0);
// Computed values (auto-track dependencies)
late final doubled = computed(() => count.val * 2);
late final isEven = computed(() => count.val % 2 == 0);
// Methods
void increment() => count.emit(count.val + 1);
void decrement() => count.emit(count.val - 1);
void reset() => count.emit(0);
// Static accessor
static CounterController get init =>
Neuron.ensure<CounterController>(() => CounterController());
}
Step 2: Use in Your UI #
class CounterPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final c = CounterController.init;
return Scaffold(
appBar: AppBar(title: Text('Neuron Counter')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Connect Signal to UI
Slot<int>(
connect: c.count,
to: (_, value) => Text(
'$value',
style: TextStyle(fontSize: 72, fontWeight: FontWeight.bold),
),
),
SizedBox(height: 16),
Slot<bool>(
connect: c.isEven,
to: (_, isEven) => Text(
isEven ? 'Even' : 'Odd',
style: TextStyle(color: isEven ? Colors.green : Colors.orange),
),
),
],
),
),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton(
onPressed: c.increment,
child: Icon(Icons.add),
),
SizedBox(height: 8),
FloatingActionButton(
onPressed: c.decrement,
child: Icon(Icons.remove),
),
],
),
);
}
}
Step 3: Run Your App #
void main() => runApp(NeuronApp(home: CounterPage()));
📚 Signal Types #
Neuron provides specialized signals for different use cases:
Signal<T> — Basic Reactive Value #
final name = Signal<String>('');
final count = Signal<int>(0);
final user = Signal<User?>(null);
// Update
name.emit('John');
count.emit(count.val + 1);
// Read
print(name.val); // 'John'
AsyncSignal<T> — Async Operations #
Handles loading, error, and data states automatically:
late final posts = asyncSignal<List<Post>>();
Future<void> loadPosts() async {
await posts.execute(() => api.fetchPosts());
}
// Check states
posts.isLoading // true during fetch
posts.hasError // true if failed
posts.hasData // true if succeeded
posts.data // the data (nullable)
posts.error // the error (nullable)
// Refresh (re-runs last operation)
await posts.refresh();
Computed<T> — Derived Values #
Auto-tracks dependencies and recalculates when they change:
late final firstName = signal('John');
late final lastName = signal('Doe');
// Dependencies detected automatically!
late final fullName = computed(() => '${firstName.val} ${lastName.val}');
firstName.emit('Jane');
print(fullName.val); // 'Jane Doe' (auto-updated)
ListSignal<E> — Reactive Lists #
late final todos = ListSignal<Todo>([]);
// Mutations that trigger updates
todos.add(Todo('Buy milk'));
todos.remove(todo);
todos.removeAt(0);
todos.insert(0, Todo('First'));
todos.clear();
// Access
todos.val.length
todos.val.first
MapSignal<K, V> — Reactive Maps #
late final settings = MapSignal<String, dynamic>({});
settings.put('theme', 'dark');
settings.remove('theme');
settings.clear();
// Access
settings['theme']
settings.containsKey('theme')
SetSignal<E> — Reactive Sets #
late final tags = SetSignal<String>({});
tags.add('flutter');
tags.remove('flutter');
tags.clear();
// Access
tags.contains('flutter')
📱 Widget Guide #
Slot<T> — Basic Connection #
Slot<int>(
connect: c.count,
to: (context, value) => Text('Count: $value'),
)
AsyncSlot<T> — Async States #
AsyncSlot<User>(
connect: c.user,
onLoading: (ctx) => CircularProgressIndicator(),
onError: (ctx, error) => Text('Error: $error'),
onData: (ctx, user) => UserCard(user),
)
MultiSlot — Multiple Signals #
// 2 signals
MultiSlot.t2(
c.firstName,
c.lastName,
to: (ctx, first, last) => Text('$first $last'),
)
// 3 signals
MultiSlot.t3(
c.width,
c.height,
c.depth,
to: (ctx, w, h, d) => Text('Volume: ${w * h * d}'),
)
// Up to 6 signals supported: t2, t3, t4, t5, t6
// Or use list for dynamic count:
MultiSlot.list(
[c.a, c.b, c.c, c.d],
to: (ctx, values) => Text('Sum: ${values.reduce((a, b) => a + b)}'),
)
ConditionalSlot — Conditional Rendering #
ConditionalSlot<bool>(
connect: c.isLoggedIn,
when: (val) => val,
to: (ctx, _) => Dashboard(),
orElse: (ctx) => LoginPage(),
)
AnimatedSlot — Animated Transitions #
AnimatedSlot<int>(
connect: c.count,
effect: SlotEffect.fade | SlotEffect.scale | SlotEffect.slideUp,
duration: Duration(milliseconds: 300),
curve: Curves.easeOutBack,
to: (ctx, value) => Text('$value'),
)
SpringSlot — Physics-Based Animations #
SpringSlot<double>(
connect: c.temperature,
spring: SpringConfig.bouncy,
to: (ctx, temp) => Text('${temp.toStringAsFixed(1)}°'),
)
GestureAnimatedSlot — Tap with Press Animation #
GestureAnimatedSlot<bool>(
connect: c.isOn,
onTap: () => c.toggle(),
pressedScale: 0.9,
to: (ctx, isOn) => Icon(
Icons.power_settings_new,
color: isOn ? Colors.green : Colors.grey,
),
)
PulseSlot — Attention-Grabbing Pulse #
PulseSlot<int>(
connect: c.alerts,
when: (count) => count > 0, // Only pulse when alerts exist
to: (ctx, count) => Badge(label: Text('$count')),
)
ShimmerSlot — Loading Shimmer Effect #
ShimmerSlot<Device?>(
connect: c.device,
when: (device) => device == null, // Shimmer while loading
shimmer: Container(height: 60, color: Colors.grey[300]),
to: (ctx, device) => DeviceCard(device!),
)
MorphSlot — Smooth Shape/Size Transitions #
MorphSlot<bool>(
connect: c.isExpanded,
config: MorphConfig.bouncy,
morphBuilder: (ctx, expanded) => MorphableWidget(
child: expanded ? ExpandedContent() : CollapsedContent(),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(expanded ? 16 : 8),
),
size: Size(double.infinity, expanded ? 200 : 60),
),
)
🏠 Smart Home Example #
A complete example showing how different Slots work together:
Controller #
class SmartHomeController extends NeuronController {
// Room states
late final livingRoomLight = $(false);
late final bedroomLight = $(false);
late final kitchenLight = $(false);
// Thermostat
late final temperature = $(22.0);
late final targetTemp = $(21.0);
late final isHeating = computed(() => temperature.val < targetTemp.val);
// Security
late final isArmed = $(false);
late final motionDetected = $(false);
late final alerts = ListSignal<String>([]);
// Device status (async loading)
late final devices = asyncSignal<List<Device>>();
// Computed states
late final lightsOn = computed(() =>
[livingRoomLight.val, bedroomLight.val, kitchenLight.val]
.where((on) => on).length
);
late final energyUsage = computed(() {
var watts = 0.0;
if (livingRoomLight.val) watts += 60;
if (bedroomLight.val) watts += 40;
if (kitchenLight.val) watts += 100;
if (isHeating.val) watts += 2000;
return watts;
});
// Actions
void toggleLight(Signal<bool> light) => light.emit(!light.val);
void setTargetTemp(double temp) => targetTemp.emit(temp.clamp(16.0, 28.0));
void armSecurity() => isArmed.emit(true);
void disarmSecurity() => isArmed.emit(false);
void dismissAlert(String alert) => alerts.remove(alert);
Future<void> loadDevices() async {
await devices.execute(() => api.fetchDevices());
}
static SmartHomeController get init => Neuron.ensure(() => SmartHomeController());
}
UI with Various Slots #
class SmartHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final c = SmartHomeController.init;
return Scaffold(
appBar: AppBar(
title: Text('Smart Home'),
actions: [
// Pulse when alerts exist
PulseSlot<List<String>>(
connect: c.alerts,
when: (alerts) => alerts.isNotEmpty,
to: (_, alerts) => Badge(
label: Text('${alerts.length}'),
child: Icon(Icons.notifications),
),
),
],
),
body: ListView(
children: [
// Energy usage with spring animation
SpringSlot<double>(
connect: c.energyUsage,
spring: SpringConfig.smooth,
to: (_, watts) => ListTile(
leading: Icon(Icons.bolt, color: Colors.amber),
title: Text('Energy Usage'),
trailing: Text('${watts.toStringAsFixed(0)}W'),
),
),
// Thermostat with morph animation
MorphSlot<bool>(
connect: c.isHeating,
config: MorphConfig(duration: Duration(milliseconds: 500)),
morphBuilder: (_, heating) => MorphableWidget(
decoration: BoxDecoration(
color: heating ? Colors.orange[100] : Colors.blue[50],
borderRadius: BorderRadius.circular(12),
),
size: Size(double.infinity, heating ? 120 : 80),
child: ListTile(
leading: Icon(
heating ? Icons.whatshot : Icons.ac_unit,
color: heating ? Colors.orange : Colors.blue,
),
title: Text(heating ? 'Heating...' : 'Cooling'),
subtitle: Slot<double>(
connect: c.temperature,
to: (_, temp) => Text('${temp.toStringAsFixed(1)}°C'),
),
),
),
),
// Light controls with gesture animations
_LightToggle('Living Room', c.livingRoomLight, Icons.weekend),
_LightToggle('Bedroom', c.bedroomLight, Icons.bed),
_LightToggle('Kitchen', c.kitchenLight, Icons.kitchen),
// Security status with animated transitions
AnimatedSlot<bool>(
connect: c.isArmed,
effect: SlotEffect.scale | SlotEffect.fade,
to: (_, armed) => ListTile(
leading: Icon(
armed ? Icons.shield : Icons.shield_outlined,
color: armed ? Colors.green : Colors.grey,
),
title: Text(armed ? 'Security Armed' : 'Security Disarmed'),
trailing: Switch(
value: armed,
onChanged: (_) => armed ? c.disarmSecurity() : c.armSecurity(),
),
),
),
// Devices with shimmer loading
ShimmerSlot<List<Device>?>(
connect: c.devices,
when: (devices) => devices == null,
shimmer: Column(
children: List.generate(3, (_) =>
Container(
height: 60,
margin: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
),
),
),
to: (_, devices) => Column(
children: devices!.map((d) => DeviceCard(d)).toList(),
),
),
],
),
);
}
}
// Reusable light toggle with press animation
class _LightToggle extends StatelessWidget {
final String name;
final Signal<bool> light;
final IconData icon;
const _LightToggle(this.name, this.light, this.icon);
@override
Widget build(BuildContext context) {
return GestureAnimatedSlot<bool>(
connect: light,
onTap: () => light.emit(!light.val),
pressedScale: 0.95,
to: (_, isOn) => ListTile(
leading: Icon(icon, color: isOn ? Colors.amber : Colors.grey),
title: Text(name),
trailing: Icon(
isOn ? Icons.lightbulb : Icons.lightbulb_outline,
color: isOn ? Colors.amber : Colors.grey,
),
),
);
}
}
🎭 More Real-World Examples #
E-Commerce Cart #
class CartController extends NeuronController {
late final items = signal<List<CartItem>>([]);
late final promoCode = signal<String?>(null);
late final subtotal = computed(() =>
items.val.fold(0.0, (sum, item) => sum + item.price * item.quantity)
);
late final discount = computed(() {
if (promoCode.val == 'SAVE20') return 0.20;
if (promoCode.val == 'SAVE10') return 0.10;
return 0.0;
});
late final total = computed(() => subtotal.val * (1 - discount.val));
late final itemCount = computed(() =>
items.val.fold(0, (sum, item) => sum + item.quantity)
);
void addItem(Product product) {
final existing = items.val.firstWhereOrNull((i) => i.productId == product.id);
if (existing != null) {
existing.quantity++;
items.emit([...items.val]); // Trigger update
} else {
items.emit([...items.val, CartItem(product)]);
}
}
void removeItem(String productId) {
items.emit(items.val.where((i) => i.productId != productId).toList());
}
void applyPromo(String code) => promoCode.emit(code);
static CartController get init => Neuron.ensure(() => CartController());
}
Authentication Flow #
class AuthController extends NeuronController {
late final user = asyncSignal<User?>();
late final isAuthenticated = computed(() => user.hasData && user.data != null);
Future<void> login(String email, String password) async {
await user.execute(() => authService.login(email, password));
}
Future<void> logout() async {
await authService.logout();
user.emitData(null);
}
Future<void> checkSession() async {
await user.execute(() => authService.getCurrentUser());
}
static AuthController get init => Neuron.ensure(() => AuthController());
}
// In your app
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return NeuronApp(
home: Slot<bool>(
connect: AuthController.init.isAuthenticated,
to: (_, isAuth) => isAuth ? HomePage() : LoginPage(),
),
);
}
}
Form Validation #
class LoginFormController extends NeuronController {
late final email = signal('');
late final password = signal('');
late final isSubmitting = signal(false);
late final emailError = computed(() {
if (email.val.isEmpty) return null;
if (!email.val.contains('@')) return 'Invalid email';
return null;
});
late final passwordError = computed(() {
if (password.val.isEmpty) return null;
if (password.val.length < 8) return 'Must be 8+ characters';
return null;
});
late final isValid = computed(() =>
email.val.isNotEmpty &&
password.val.isNotEmpty &&
emailError.val == null &&
passwordError.val == null
);
Future<void> submit() async {
if (!isValid.val) return;
isSubmitting.emit(true);
try {
await AuthController.init.login(email.val, password.val);
} finally {
isSubmitting.emit(false);
}
}
static LoginFormController get init => Neuron.ensure(() => LoginFormController());
}
Search with Debounce #
class SearchController extends NeuronController {
late final query = signal('');
late final results = asyncSignal<List<Product>>();
// Debounced search
late final _debouncedQuery = DebouncedSignal(query, Duration(milliseconds: 300));
@override
void onInit() {
// Search when debounced query changes
effect(() {
if (_debouncedQuery.val.length >= 2) {
results.execute(() => api.search(_debouncedQuery.val));
}
}, [_debouncedQuery]);
}
static SearchController get init => Neuron.ensure(() => SearchController());
}
🎨 Animation Effects #
Neuron includes beautiful animation effects:
| Effect | Description |
|---|---|
SlotEffect.fade |
Fade in/out |
SlotEffect.scale |
Scale up/down |
SlotEffect.slideUp |
Slide from bottom |
SlotEffect.slideDown |
Slide from top |
SlotEffect.slideLeft |
Slide from right |
SlotEffect.slideRight |
Slide from left |
SlotEffect.rotate |
Rotation |
SlotEffect.blur |
Blur effect |
SlotEffect.flip |
3D flip |
Combine effects with |:
AnimatedSlot<int>(
connect: c.count,
effect: SlotEffect.fade | SlotEffect.scale | SlotEffect.slideUp,
to: (_, value) => Text('$value'),
)
🧭 Navigation #
Context-free navigation:
// Push
Neuron.to(NextPage());
// Replace
Neuron.off(LoginPage());
// Back
Neuron.back();
// Clear stack
Neuron.offAll(HomePage());
// Named routes
Neuron.toNamed('/profile/123');
🔧 Advanced Features #
Middleware #
final age = MiddlewareSignal<int>(0, middlewares: [
ClampMiddleware(min: 0, max: 120),
LoggingMiddleware(label: 'age'),
]);
Persistence #
final theme = PersistentSignal<String>(
'light',
persistence: SimplePersistence(key: 'theme', ...),
);
Undo/Redo #
final text = UndoableSignal<String>('');
text.emit('Hello');
text.emit('World');
text.undo(); // 'Hello'
text.redo(); // 'World'
📊 Performance #
- Fine-grained: Only connected widgets rebuild
- Lazy computed: Values calculated only when accessed
- Efficient: Optimized listener notification
- Auto-cleanup: Memory managed automatically
🤝 Contributing #
Contributions welcome! Please read CONTRIBUTING.md.
📄 License #
MIT License - see LICENSE.
Built with ❤️ for the Flutter community