pipe_x 1.4.4
pipe_x: ^1.4.4 copied to clipboard
A lightweight, reactive state management library for Flutter with fine-grained reactivity and minimal boilerplate.
๐ง PipeX State Management #
PipeX is a Flutter library designed for state management, utilizing pipeline architecture. It focuses on precise reactivity and streamlined code to enhance development.
๐ซ No Streams | ๐ซ No Dependency Injection | ๐ซ No Keys For Widget Updates
PipeX eliminates boilerplate.
Just pure, fine-grained reactivity with Dart Object Manipulation and Custom Elements
๐ Table of Contents #
- What is PipeX?
- Core Concepts
- Quick Start
- Core Components
- Advanced Features
- Common Patterns
- Best Practices
- Migration Guide
- API Reference
- Examples
What is PipeX? #
PipeX is a lightweight, reactive state management library for Flutter that emphasizes:
- โจ Fine-grained reactivity: Only the widgets that depend on changed state rebuild
- ๐ Automatic lifecycle management: No manual cleanup, everything disposes automatically
- ๐ฏ Simplicity: Minimal boilerplate, intuitive API
- ๐ Type safety: Full Dart type system support
- ๐ฆ Declarative: State flows naturally through your widget tree
- โก Performance: Direct Element manipulation for optimal rebuilds
Core Metaphor #
The library uses a plumbing/water metaphor:
- Pipe: Carries values (water) through your application
- Hub: Central junction where multiple pipes connect and are managed
- Sink: Where values flow into your UI and cause updates
- Well: Deeper reservoir that draws from multiple pipes at once
- HubListener: Valve that triggers actions without affecting the flow
Core Concepts #
1. Pipe #
A Pipe holds a value and notifies subscribers when it changes.
final counter = Pipe(0); // Create a pipe with initial value
counter.value++; // Update value โ triggers rebuilds
print(counter.value); // Read current value
2. Hub - The State Manager #
A Hub groups related Pipes and manages their lifecycle.
class CounterHub extends Hub {
late final count = pipe(0); // Auto-registered!
late final name = pipe('John'); // Auto-disposed!
void increment() => count.value++;
}
3. Sink - Single Pipe Subscriber #
Sink rebuilds when a single Pipe changes.
Sink(
pipe: hub.count,
builder: (context, value) => Text('$value'),
)
4. Well - Multiple Pipe Subscriber #
Well rebuilds when ANY of multiple Pipes change.
Well(
pipes: [hub.count, hub.name],
builder: (context) {
final hub = context.read<MyHub>();
return Text('${hub.name.value}: ${hub.count.value}');
},
)
5. HubProvider - Dependency Injection #
Provides a Hub to the widget tree and manages its lifecycle.
HubProvider(
create: () => CounterHub(),
child: HomeScreen(),
)
6. HubListener - Side Effects #
Triggers callbacks based on conditions without rebuilding its child.
HubListener<CounterHub>( // Defining Type of Listner is Mandatory or Listner Will throw state error
listenWhen: (hub) => hub.count.value == 10,
onConditionMet: () => print('Count reached 10!'),
child: MyWidget(),
)
Quick Start #
1. Add Dependency #
dependencies:
pipe_x: ^latest_version
2. Create a Hub #
import 'package:pipe_x/pipe_x.dart';
class CounterHub extends Hub {
// Use pipe() to create pipes - automatically registered and disposed!
late final count = pipe(0);
// Business logic
void increment() => count.value++;
void decrement() => count.value--;
void reset() => count.value = 0;
// Computed values with getters
bool get isEven => count.value % 2 == 0;
String get label => 'Count: ${count.value}';
}
3. Provide the Hub #
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: HubProvider(
create: () => CounterHub(),
child: CounterScreen(),
),
);
}
}
4. Use in UI #
class CounterScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hub = context.read<CounterHub>();
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Only this Sink rebuilds when count changes
Sink(
pipe: hub.count,
builder: (context, value) => Text(
'$value',
style: TextStyle(fontSize: 48),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: hub.decrement,
child: Text('-'),
),
SizedBox(width: 16),
ElevatedButton(
onPressed: hub.increment,
child: Text('+'),
),
],
),
],
),
),
);
}
}
Core Components #
Pipe #
Purpose: Reactive container for a value of type T.
Creating Pipes
// Standalone (auto-disposes when no subscribers)
final count = Pipe(0);
// In a Hub (Hub manages disposal)
class MyHub extends Hub {
late final count = pipe(0);
}
Methods
// Read value
int value = pipe.value;
// Update value (notifies if changed)
pipe.value = newValue;
// Force update (even if value unchanged)
pipe.pump(newValue);
// Add listener for side effects
pipe.addListener(() => print('Changed!'));
// Remove listener
pipe.removeListener(callback);
// Dispose manually (not needed in Hub)
pipe.dispose();
Disposed Check
// All operations check if pipe is disposed
pipe.value; // Throws StateError if disposed
pipe.value = 5; // Throws StateError if disposed
pipe.pump(5); // Throws StateError if disposed
Hub #
Purpose: State manager that groups related Pipes.
Creating a Hub
class ShoppingCartHub extends Hub {
// Pipes (automatically registered!)
late final items = pipe<List<Product>>([]);
late final discount = pipe(0.0);
late final isLoading = pipe(false);
// Computed values (use getters)
double get subtotal => items.value.fold(0, (sum, item) => sum + item.price);
double get total => subtotal * (1 - discount.value);
int get itemCount => items.value.length;
// Business logic (methods)
void addItem(Product product) {
items.value = [...items.value, product];
}
void removeItem(String productId) {
items.value = items.value.where((item) => item.id != productId).toList();
}
void applyDiscount(double percent) {
discount.value = percent.clamp(0.0, 1.0);
}
// Cleanup (optional)
@override
void onDispose() {
// Custom cleanup like canceling timers, closing streams, etc.
}
}
Hub Methods
// Listen to all pipe changes in this hub
final removeListener = hub.addListener(() {
print('Something changed!');
});
// Later: removeListener();
// Get total subscriber count (debugging)
int count = hub.subscriberCount;
// Check if disposed
bool isDisposed = hub.disposed;
// Dispose (usually done by HubProvider)
hub.dispose();
Sink #
Purpose: Widget that subscribes to a single Pipe and rebuilds when it changes.
Sink<int>(
pipe: hub.counter,
builder: (context, value) => Text('Count: $value'),
)
When to use: Single Pipe, type-safe access to value in builder.
Well #
Purpose: Widget that subscribes to multiple Pipes and rebuilds when ANY change.
Well(
pipes: [hub.firstName, hub.lastName, hub.age],
builder: (context) {
final hub = context.read<UserHub>();
return Text(
'${hub.firstName.value} ${hub.lastName.value}, ${hub.age.value}',
);
},
)
When to use: Multiple Pipes, computed values from multiple sources.
HubProvider #
Purpose: Provides a Hub to the widget tree and manages its lifecycle.
HubProvider<CounterHub>(
create: () => CounterHub(),
child: MyApp(),
)
Access Methods
// context.read<T>() - No rebuild dependency (use in callbacks)
final hub = context.read<CounterHub>();
hub.increment();
// HubProvider.of<T>(context) - Creates dependency (rarely needed)
final hub = HubProvider.of<CounterHub>(context);
// HubProvider.read<T>(context) - Same as context.read<T>()
final hub = HubProvider.read<CounterHub>(context);
MultiHubProvider #
Purpose: Provide multiple Hubs without nesting.
MultiHubProvider(
hubs: [
() => AuthHub(),
() => ThemeHub(),
() => SettingsHub(),
],
child: MyApp(),
)
Access: Same as HubProvider - use context.read<T>().
HubListener #
Purpose: Execute side effects based on Hub state without rebuilding the child.
HubListener<CounterHub>(
listenWhen: (hub) => hub.count.value == 10,
onConditionMet: () {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text('Count reached 10!'),
),
);
},
child: MyWidget(), // This never rebuilds due to the listener
)
When to use:
- Navigation based on state
- Show dialogs/snackbars
- Analytics/logging
- Any side effect that shouldn't trigger a rebuild
Advanced Features #
1. Mutable Objects with pump() #
For objects where you mutate internal state without changing the reference:
class User {
String name;
int age;
User({required this.name, required this.age});
}
class UserHub extends Hub {
late final user = pipe(User(name: 'John', age: 25));
void updateName(String name) {
user.value.name = name; // Mutate object
user.pump(user.value); // Force update (reference unchanged)
}
}
Why? shouldNotify() checks reference equality. Mutations don't change the reference, so pump() bypasses the check.
Alternative (preferred): Use immutable updates:
void updateName(String name) {
user.value = User(
name: name,
age: user.value.age,
);
}
2. Hub Listeners #
Listen to all changes in a Hub for side effects:
class MyWidget extends StatefulWidget {
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
late final VoidCallback _removeListener;
@override
void initState() {
super.initState();
final hub = context.read<DataHub>();
// _removeListener is a function that removes the listener from the hub.
// You may or may not call it in dispose, it's up to you.
// If you don't call it in dispose, the listener will be also disposed when the hub is disposed.
_removeListener = hub.addListener(() {
// Called whenever ANY pipe in this hub changes
print('Hub state changed!');
});
}
@override
void dispose() {
_removeListener(); // Cleanup
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container();
}
}
Better alternative: Use HubListener widget (handles lifecycle automatically).
3. Nested Reactive Widgets & Element Boundaries #
PipeX doesn't stop you from using multiple reactive widgets โ it just prevents you from nesting them inside the same reactive subtree.
That "same subtree" part is key.
โ Invalid: Nested Sinks in Same Build Subtree
Sink(
pipe: hub.pipe1,
builder: (_) {
return Column(
children: [
Text('Outer Sink'),
Sink(
pipe: hub.pipe2,
builder: (_) {
return Text('Inner Sink');
},
),
],
);
},
);
In this case, both Sinks belong to the same build subtree. The inner one is literally created inside the build of the outer one. So if the outer Sink rebuilds, the inner one rebuilds too โ even if its data didn't change.
PipeX detects this and throws an assertion to prevent redundant rebuilds.
โ Valid: Sinks in Separate Component Subtrees
Sink(
pipe: hub.pipe1,
builder: (_) => MyComponent(),
);
And inside MyComponent:
class MyComponent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Sink(
pipe: hub.pipe2,
builder: (_) {
return Text('Inner Sink inside its own component');
},
);
}
}
This is fine because MyComponent creates its own Element subtree. That means the inner Sink lives in a totally separate part of the UI tree โ not nested in the same build scope.
Why This Distinction Matters
A Widget in Flutter is just a configuration โ basically a blueprint or description of how something should look.
An Element is the actual mounted instance in the tree โ the thing Flutter updates, rebuilds, and manages.
When you call build(), Flutter walks the Element tree, not the widget tree. PipeX attaches itself to these Elements and tracks reactive builders at that level.
So when it says "no nested Sinks," it's not checking widgets โ it's checking whether two reactive Elements exist inside the same build subtree.
In Short
PipeX isn't limiting composition โ it's enforcing clean reactivity boundaries at the Element level. This guarantees precise rebuild control and predictable updates โ without relying on developer discipline to manage rebuild scopes
4. Standalone Pipes (Auto-Dispose) #
Pipes can be used outside Hubs with automatic disposal:
class MyWidget extends StatefulWidget {
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
final counter = Pipe(0); // Auto-disposes when no subscribers
@override
Widget build(BuildContext context) {
return Column(
children: [
Sink(
pipe: counter,
builder: (context, value) => Text('$value'),
),
ElevatedButton(
onPressed: () => counter.value++,
child: Text('+'),
),
],
);
}
}
Auto-dispose behavior: When the last widget unsubscribes (widget unmounts), the Pipe automatically disposes itself.
Common Patterns #
Pattern 1: Form Management #
class LoginHub extends Hub {
late final email = pipe('');
late final password = pipe('');
late final isLoading = pipe(false);
late final error = pipe<String?>(null);
// Validation
bool get isEmailValid => email.value.contains('@');
bool get isPasswordValid => password.value.length >= 6;
bool get canSubmit => isEmailValid && isPasswordValid && !isLoading.value;
Future<void> login() async {
if (!canSubmit) return;
isLoading.value = true;
error.value = null;
try {
await authService.login(email.value, password.value);
} catch (e) {
error.value = e.toString();
} finally {
isLoading.value = false;
}
}
}
Pattern 2: Async Data Loading #
class DataHub extends Hub {
late final isLoading = pipe(false);
late final error = pipe<String?>(null);
late final userProfile = pipe<UserProfile?>(null);
late final gender = pipe<String>('Male');
late final age = pipe<int>(25);
Future<void> fetchUserProfile() async {
isLoading.value = true;
error.value = null;
try {
await Future.delayed(Duration(seconds: 2)); // Simulate API
userProfile.value = UserProfile(
id: 'USR-12345',
name: 'John Doe',
email: 'john@example.com',
// ... more fields
);
gender.value = 'Male';
age.value = 28;
} catch (e) {
error.value = e.toString();
} finally {
isLoading.value = false;
}
}
}
UI with Loading Overlay:
Stack(
children: [
// Main content
Sink(
pipe: hub.userProfile,
builder: (context, profile) {
if (profile == null) return Center(child: Text('No data'));
return ProfileView(profile: profile);
},
),
// Loading overlay
Sink(
pipe: hub.isLoading,
builder: (context, isLoading) {
if (!isLoading) return SizedBox.shrink();
return Container(
color: Colors.black54,
child: Center(child: CircularProgressIndicator()),
);
},
),
],
)
Pattern 3: Computed Values #
class CartHub extends Hub {
late final items = pipe<List<CartItem>>([]);
late final taxRate = pipe(0.08);
late final couponDiscount = pipe(0.0);
// Computed with getters
double get subtotal => items.value.fold(0.0, (sum, item) => sum + item.price);
double get tax => subtotal * taxRate.value;
double get discount => subtotal * couponDiscount.value;
double get total => subtotal + tax - discount;
}
Pattern 4: Scoped vs Global State #
Global Hub (app-wide):
MaterialApp(
home: MultiHubProvider(
hubs: [
() => AuthHub(), // Lives for app lifetime
() => ThemeHub(), // Lives for app lifetime
() => SettingsHub(), // Lives for app lifetime
],
child: HomeScreen(),
),
)
Scoped Hub (screen-specific):
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => HubProvider(
create: () => EditProductHub(product), // Disposed on pop
child: EditProductScreen(),
),
),
)
Best Practices #
1. Keep Sinks Small #
โ Bad - Entire screen rebuilds:
Sink(
pipe: hub.counter,
builder: (context, value) => Scaffold(...), // Too large!
)
โ Good - Only necessary parts rebuild:
Scaffold(
appBar: AppBar(
title: Sink(
pipe: hub.counter,
builder: (context, value) => Text('$value'), // Granular!
),
),
body: ProfileBody(), // Never rebuilds
)
2. Use Getters for Computed Values #
โ Bad - Redundant state:
class CounterHub extends Hub {
late final count = pipe(0);
late final isEven = pipe(false); // Don't do this!
void increment() {
count.value++;
isEven.value = count.value % 2 == 0; // Manual sync
}
}
โ Good - Computed:
class CounterHub extends Hub {
late final count = pipe(0);
bool get isEven => count.value % 2 == 0; // Computed!
void increment() => count.value++;
}
3. Use Well for Multiple Pipes #
โ Bad - Nested Sinks:
Sink(
pipe: hub.firstName,
builder: (context, first) => Sink(
pipe: hub.lastName,
builder: (context, last) => Text('$first $last'),
),
)
โ Good - Single Well:
Well(
pipes: [hub.firstName, hub.lastName],
builder: (context) {
final hub = context.read<UserHub>();
return Text('${hub.firstName.value} ${hub.lastName.value}');
},
)
4. Separation of Concerns #
โ Bad - Logic in UI:
ElevatedButton(
onPressed: () {
final cart = context.read<CartHub>();
cart.items.value = [...cart.items.value, newItem];
cart.total.value = cart.items.value.fold(0, (sum, i) => sum + i.price);
},
child: Text('Add'),
)
โ Good - Logic in Hub:
// In Hub:
void addItem(CartItem item) {
items.value = [...items.value, item];
}
// In UI:
@override
Widget build(BuildContext context) {
final hub = context.read<CartHub>();
return ElevatedButton(
onPressed: () => hub.addItem(newItem),
child: Text('Add'),
);
}
5. Proper Error Handling #
class DataHub extends Hub {
late final data = pipe<List<Item>>([]);
late final isLoading = pipe(false);
late final error = pipe<String?>(null);
Future<void> fetchData() async {
isLoading.value = true;
error.value = null; // Clear previous errors
try {
final result = await api.getData();
data.value = result;
} catch (e) {
error.value = e.toString();
// Optional: Log to analytics
} finally {
isLoading.value = false; // Always cleanup
}
}
}
6. Use HubListener for Side Effects #
โ Bad - Side effects in build:
@override
Widget build(BuildContext context) {
final hub = context.read<CartHub>();
if (hub.itemCount.value > 10) {
// โ Don't do this in build!
showDialog(...);
}
return MyWidget();
}
โ Good - Use HubListener:
HubListener<CartHub>(
listenWhen: (hub) => hub.items.value.length > 10,
onConditionMet: () {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text('Cart has more than 10 items!'),
),
);
},
child: MyWidget(),
)
Migration Guide #
From setState #
Before:
class _CounterState extends State<CounterScreen> {
int _count = 0;
void _increment() => setState(() => _count++);
@override
Widget build(BuildContext context) {
return Text('$_count'); // Entire widget rebuilds
}
}
After:
class CounterHub extends Hub {
late final count = pipe(0);
void increment() => count.value++;
}
class CounterScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hub = context.read<CounterHub>();
return Sink(
pipe: hub.count,
builder: (context, value) => Text('$value'), // Only Text rebuilds
);
}
}
From Provider/ChangeNotifier #
Before:
class Counter with ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners(); // Manual notification
}
}
// Usage
Consumer<Counter>(
builder: (context, counter, child) => Text('${counter.count}'),
)
After:
class CounterHub extends Hub {
late final count = pipe(0);
void increment() => count.value++; // Auto-notification
}
// Usage
@override
Widget build(BuildContext context) {
final hub = context.read<CounterHub>();
return Sink(
pipe: hub.count,
builder: (context, value) => Text('$value'),
);
}
From BLoC #
Before:
// Events
class IncrementEvent extends CounterEvent {}
// State
class CounterState {
final int count;
CounterState(this.count);
CounterState copyWith({int? count}) => CounterState(count ?? this.count);
}
// BLoC
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(CounterState(0)) {
on<IncrementEvent>((event, emit) {
emit(state.copyWith(count: state.count + 1));
});
}
}
// Usage
BlocBuilder<CounterBloc, CounterState>(
builder: (context, state) => Text('${state.count}'),
)
// Actions
context.read<CounterBloc>().add(IncrementEvent());
After:
// Hub (combines BLoC + State)
class CounterHub extends Hub {
late final count = pipe(0);
void increment() => count.value++; // Direct!
}
// Usage
@override
Widget build(BuildContext context) {
final hub = context.read<CounterHub>();
return Column(
children: [
Sink(
pipe: hub.count,
builder: (context, value) => Text('$value'),
),
ElevatedButton(
onPressed: hub.increment, // Direct!
child: Text('+'),
),
],
);
}
Key differences:
- โ No Event classes
- โ No State classes with copyWith
- โ No emit()
- โ Direct method calls
- โ Automatic notifications
- โ Less boilerplate
API Reference #
Pipe #
// Constructor
Pipe(T initialValue, {bool? autoDispose})
// Properties
T value // Get/set value
bool disposed // Check if disposed
int subscriberCount // Number of subscribers
// Methods
void pump(T newValue) // Force update
void addListener(VoidCallback callback)
void removeListener(VoidCallback callback)
void dispose() // Cleanup
Hub #
// Constructor
Hub()
// Properties
bool disposed // Check if disposed
int subscriberCount // Total subscribers
// Methods (Protected)
@protected Pipe<T> pipe<T>(T initialValue, {String? key})
@protected T registerPipe<T extends Pipe>(T pipe, [String? key])
@protected void checkNotDisposed()
@protected void onDispose()
// Public Methods
VoidCallback addListener(VoidCallback callback)
void dispose()
Sink #
Sink({
required Pipe<T> pipe,
required Widget Function(BuildContext, T) builder,
})
Well #
Well({
required List<Pipe> pipes,
required Widget Function(BuildContext) builder,
})
HubProvider #
HubProvider({
required T Function() create,
required Widget child,
})
// Static methods
static T of<T extends Hub>(BuildContext context)
static T read<T extends Hub>(BuildContext context)
HubListener #
HubListener({
required bool Function(T hub) listenWhen,
required VoidCallback onConditionMet,
required Widget child,
})
MultiHubProvider #
MultiHubProvider({
required List<Hub Function()> hubs,
required Widget child,
})
BuildContext Extension #
extension HubBuildContextExtension on BuildContext {
T read<T extends Hub>() // Same as HubProvider.read<T>(this)
}
Examples #
Check the /example folder for comprehensive examples:
- Basic Counter - Simple state management
- Multiple Sinks - Granular rebuilds
- Well Widget - Multiple pipe subscriptions
- Form Management - Input handling with validation
- Computed Values - Getters for derived state
- List Management - Adding/removing items
- Conditional Rendering - Loading/error/success states
- Multi-Hub - Multiple state managers
- Scoped Hub - Screen-specific state
- Side Effects - HubListener for actions
- Async Operations - API calls with loading states
- Mutable Objects - Using pump() for complex objects
Performance Considerations #
- Granular Rebuilds: PipeX rebuilds only the exact widgets subscribed to changed state
- No Unnecessary Subscriptions: Use
context.read<T>()in callbacks (no rebuild dependency) - Element-Level Control: Direct
markNeedsBuild()calls for optimal performance - Auto-Disposal: Automatic cleanup prevents memory leaks
- Type Safety: Compile-time checks prevent runtime errors
Testing #
Unit Testing Hubs #
test('CounterHub increments', () {
final hub = CounterHub();
expect(hub.count.value, 0);
hub.increment();
expect(hub.count.value, 1);
hub.dispose(); // Cleanup
});
Widget Testing #
testWidgets('Sink rebuilds on pipe change', (tester) async {
final hub = CounterHub();
await tester.pumpWidget(
MaterialApp(
home: HubProvider(
create: () => hub,
child: Sink(
pipe: hub.count,
builder: (context, value) => Text('$value'),
),
),
),
);
expect(find.text('0'), findsOneWidget);
hub.increment();
await tester.pump();
expect(find.text('1'), findsOneWidget);
});
Common Questions #
Q: Can I use PipeX with other state management?
A: Yes! PipeX works alongside Provider, BLoC, Riverpod, etc.
Q: How do I persist state?
A: Add persistence in your Hub methods:
class CounterHub extends Hub {
late final count = pipe(prefs.getInt('count') ?? 0);
void increment() {
count.value++;
prefs.setInt('count', count.value);
}
}
Q: Does PipeX work without Flutter?
A: Core classes (Pipe, Hub) work in pure Dart. Widgets require Flutter.
Q: What about code generation?
A: PipeX intentionally avoids code generation for simplicity.
License & Credits #
Design Inspirations:
- MobX: Reactivity concepts
- Provider: Dependency injection
- Signals: Fine-grained reactivity
- BLoC: Business logic separation
Philosophy: Take the best ideas and create something simpler.
Contributing #
Found a bug? Have a feature request? Please file an issue!
Support #
- ๐ Documentation
- ๐ฌ Discord Community
- ๐ Issue Tracker
- โญ Star on GitHub
Happy coding with PipeX! ๐งโจ
