State Management

Flutter Signals: I Replaced Riverpod with Signals in 3 Apps — Here's What Happened

Muhammad Shakil Muhammad Shakil
Mar 02, 2026
24 min read
Flutter Signals: Reactive State Management in Flutter apps with fine-grained reactivity
Back to Blog

Last September, I was building a dashboard app for a logistics client and hit the point where my Riverpod provider count crossed 60. Every new feature meant a new provider, a new notifier class, a new generated file, and more ref.watch() chains that were getting harder to trace. A friend sent me a link to the signals package by Rody Davis and said "just try it on one feature." I tried it. Then I rewrote the entire dashboard module. Then I migrated two more client apps. Six months later, I have a clear picture of where signals shine and where they fall short — and it's not the story you'll read in most blog posts about this package.

If you're comparing state management approaches right now, I wrote a full state management comparison for 2026 that covers Bloc, Riverpod, Provider, GetX, and signals side by side. This post goes deep on signals specifically — the API, the patterns, the real production code, and the honest tradeoffs after living with it for half a year.

Why I Tried Signals in the First Place

Two things pushed me. First, Riverpod's code generation overhead was slowing down my development cycle. Every change to a provider required dart run build_runner build, and on a large project that took 8-12 seconds. I was running it dozens of times per day. Second, I had a specific performance problem: a data table with 500 rows where changing a single cell value caused the entire table widget to rebuild because Riverpod's reactivity operates at the provider level, not the variable level.

Signals promised fine-grained reactivity — the idea that when a single value changes, only the exact widget reading that value rebuilds. No provider tree, no code generation, no ProviderScope. Just a reactive variable and the widgets that care about it. That promise turned out to be real, but the full picture is more nuanced than "signals good, Riverpod bad." I'll explain exactly what I mean throughout this post.

Package Clarification

The package is called signals (the core Dart library) and signals_flutter (Flutter-specific bindings with the Watch widget). Both are by Rody Davis, a developer advocate at Google. Don't confuse it with other packages — signals is the one with active maintenance and a growing community.

What Signals Actually Are

Signals are reactive primitives. The concept comes from JavaScript frameworks — SolidJS popularized them, then Preact adopted them, and now they're in Angular, Svelte, and dozens of other frameworks. Rody Davis brought the concept to Dart, and the mental model translates perfectly because Dart already has excellent support for reactive programming through Stream and ValueNotifier.

A signal is like a ValueNotifier that automatically tracks who is reading it. When you read a signal's value inside a Watch widget or a computed function, the signal records that dependency. When the signal's value changes, it notifies exactly those dependents — nothing else. No subscription management, no addListener / removeListener, no dispose() boilerplate. The tracking is automatic.

// A signal is just a reactive variable
final counter = signal(0);

// Reading it creates a dependency automatically
print(counter.value); // 0

// Setting it notifies all dependents
counter.value = 5; // Anything "watching" counter now updates

If you've used ValueNotifier with ValueListenableBuilder, signals feel familiar but with two major differences: automatic dependency tracking (no manual subscriptions) and computed derivations (more on that next). This is fundamentally different from how Bloc handles events or how Riverpod manages provider dependencies.

The Three Core Primitives: Signal, Computed, Effect

Everything in the signals library builds on three primitives. Once you understand these, you understand the entire API.

Signal — The Reactive Variable

A signal holds a value and notifies dependents when it changes. Create it with signal(), read with .value, write with .value =:

import 'package:signals/signals.dart';

final userName = signal('');
final itemCount = signal(0);
final isLoggedIn = signal(false);
final cartItems = signal<List<CartItem>>([]);

// Read
print(userName.value); // ''

// Write
userName.value = 'Shakil';

// For collections, replace the entire reference
cartItems.value = [...cartItems.value, newItem];

Computed — Derived State That Caches

A computed signal derives its value from other signals. It only recalculates when a source signal changes, and it caches the result. This is the primitive that made me fall in love with signals — I had so many places in my Riverpod code where I was manually combining providers, and computed handles it automatically:

final firstName = signal('Muhammad');
final lastName = signal('Shakil');

// Automatically recomputes when firstName OR lastName changes
final fullName = computed(() => '${firstName.value} ${lastName.value}');

// Computed chains work too
final greeting = computed(() => 'Hello, ${fullName.value}!');

// Only recalculates when source signals actually change
print(fullName.value); // 'Muhammad Shakil'
firstName.value = 'Ali';
print(fullName.value); // 'Ali Shakil' — recomputed
print(fullName.value); // 'Ali Shakil' — cached, no recomputation

The caching behavior is critical. In a Riverpod app, if you have a provider that depends on three other providers, it recalculates whenever any of them changes even if the final output is the same. Computed signals compare the output — if the derived value hasn't actually changed, dependents aren't notified. For apps that display aggregated data from multiple sources, this eliminates a category of unnecessary rebuilds I used to fight with select() in Riverpod.

Computed vs select() in Riverpod

Riverpod's ref.watch(provider.select((s) => s.someField)) does similar filtering but requires you to explicitly define what to select. Computed signals track dependencies automatically — read any signal inside the computed function and it becomes a dependency. Less code, less maintenance, fewer chances to forget a select() call and cause an unnecessary rebuild.

Effect — Side Effects on State Changes

An effect runs a callback every time any signal it reads changes. I use it for things that shouldn't cause UI rebuilds but need to react to state: persisting to SharedPreferences, logging, sending analytics events:

final theme = signal('light');

// Runs immediately, then re-runs whenever theme.value changes
final dispose = effect(() {
 final currentTheme = theme.value;
 // Save to SharedPreferences
 prefs.setString('theme', currentTheme);
 debugPrint('Theme changed to: $currentTheme');
});

// Later, clean up
dispose();

Never Modify Signals Inside an Effect

Effects should be read-only reactions. If you set a signal's value inside an effect that reads that same signal, you create an infinite loop. I hit this on day two and the app froze. The rule is simple: effects react to changes, computed signals derive new values, and regular code drives mutations. Keep these roles separate.

Using Signals in Flutter with the Watch Widget

The signals_flutter package adds Flutter integration through the Watch widget. This is how signals connect to your UI — and it's where the fine-grained reactivity really pays off:

import 'package:signals_flutter/signals_flutter.dart';

final counter = signal(0);
final doubleCount = computed(() => counter.value * 2);

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

 @override
 Widget build(BuildContext context) {
 return Scaffold(
 appBar: AppBar(title: const Text('Signals Counter')),
 body: Column(
 mainAxisAlignment: MainAxisAlignment.center,
 children: [
 // Only THIS Watch widget rebuilds when counter changes
 Watch((context) => Text(
 'Count: ${counter.value}',
 style: Theme.of(context).textTheme.headlineMedium,
 )),
 const SizedBox(height: 16),
 // Only THIS Watch widget rebuilds when doubleCount changes
 Watch((context) => Text(
 'Double: ${doubleCount.value}',
 style: Theme.of(context).textTheme.titleLarge,
 )),
 const SizedBox(height: 32),
 // This button never rebuilds
 ElevatedButton(
 onPressed: () => counter.value++,
 child: const Text('Increment'),
 ),
 ],
 ),
 );
 }
}

Notice what's different from Riverpod: the CounterPage itself doesn't extend ConsumerWidget. It's a plain StatelessWidget. The reactive boundary is at the Watch widget level, not the entire widget. When counter.value changes, only the first Watch rebuilds. The second Watch also rebuilds because doubleCount depends on counter (and the value changed). The button and the rest of the scaffold? Untouched. In a complex screen with 20 data points, this granularity makes a visible difference. The performance gains compound as your screens get more complex.

Signals vs Riverpod — Real Code Comparison

I'll show the same feature — a search with debounce and filtered results — implemented in both. This is a pattern from my logistics dashboard app that I migrated from Riverpod to signals:

// RIVERPOD VERSION — 4 files, code generation required
// search_state.dart
@freezed
class SearchState with _$SearchState {
 const factory SearchState({
 @Default('') String query,
 @Default([]) List<Item> results,
 @Default(false) bool isLoading,
 }) = _SearchState;
}

// search_notifier.dart
@riverpod
class SearchNotifier extends _$SearchNotifier {
 Timer? _debounce;

 @override
 SearchState build() => const SearchState();

 void updateQuery(String query) {
 state = state.copyWith(query: query);
 _debounce?.cancel();
 _debounce = Timer(const Duration(milliseconds: 300), _search);
 }

 Future<void> _search() async {
 state = state.copyWith(isLoading: true);
 final api = ref.read(apiProvider);
 final results = await api.search(state.query);
 state = state.copyWith(results: results, isLoading: false);
 }
}
// SIGNALS VERSION — 1 file, no code generation
final searchQuery = signal('');
final isSearching = signal(false);
final searchResults = signal<List<Item>>([]);

Timer? _debounce;

void updateSearch(String query) {
 searchQuery.value = query;
 _debounce?.cancel();
 _debounce = Timer(const Duration(milliseconds: 300), () async {
 isSearching.value = true;
 final results = await apiClient.search(query);
 searchResults.value = results;
 isSearching.value = false;
 });
}

// In the widget:
Watch((context) => isSearching.value
 ? const CircularProgressIndicator()
 : ListView.builder(
 itemCount: searchResults.value.length,
 itemBuilder: (_, i) => ItemTile(item: searchResults.value[i]),
 ),
)

The signals version is half the code. No freezed model, no generated files, no ref.read(). I can see the entire search flow in one glance. For the dashboard app, this difference multiplied across 30 features meant thousands fewer lines and a build step I didn't need anymore. If you're interested in how I made similar tradeoff decisions for a Riverpod 3 migration, the reasoning is similar — less boilerplate, faster iteration.

The Tradeoff

The Riverpod version has explicit state modeling with freezed, built-in disposal when the provider goes out of scope, and testable dependency injection through ref. The signals version is simpler but the architecture decisions (where to put signals, how to inject services, when to dispose) are on you. For small to mid-sized features, signals win on speed. For complex domain logic with many dependencies, Riverpod's structure pays for itself.

Signals vs Bloc — When Each Wins

I get asked this a lot, especially from teams already committed to Bloc patterns. The comparison isn't straightforward because they solve different problems at different levels of abstraction.

Bloc wins when you need event-driven state machines with auditable state transitions. Think authentication flows (login → verifying → authenticated → expired), payment processing, or multi-step forms where the order of transitions matters and you need to test every path. Bloc's event/state separation forces you to model these transitions explicitly.

Signals win when you need reactive data that many widgets read independently. Dashboards, data tables, forms with computed validations, any screen where 10+ pieces of state drive different parts of the UI. The fine-grained reactivity means changing one field on a form doesn't rebuild the entire screen.

// BLOC — Great for complex state machines
class AuthBloc extends Bloc<AuthEvent, AuthState> {
 AuthBloc(this._authRepo) : super(const AuthState.initial()) {
 on<LoginRequested>(_onLogin);
 on<LogoutRequested>(_onLogout);
 on<TokenExpired>(_onTokenExpired);
 }
 // Every transition is explicit and testable
}

// SIGNALS — Great for reactive data display
final userName = signal('');
final userEmail = signal('');
final isVerified = signal(false);
final profileComplete = computed(() =>
 userName.value.isNotEmpty &&
 userEmail.value.isNotEmpty &&
 isVerified.value);
// Each piece updates independently, UI rebuilds minimally

In practice, I use both in the same app. Bloc handles the auth flow and complex business workflows. Signals handle the UI state within each feature screen. They complement each other well because signals don't try to be an architecture framework — they're just reactive variables.

Fine-Grained Reactivity — Why It Matters

Let me show you the concrete performance difference that convinced me. This was the data table from the logistics dashboard — 500 rows, 8 columns, inline editing on each cell:

// With Riverpod: changing one cell rebuilds all 500 rows
// because the table data is one provider holding a List<Row>
final tableDataProvider = StateNotifierProvider<...>(...);
// Widget: ref.watch(tableDataProvider) => entire table rebuilds

// With Signals: changing one cell rebuilds one cell
// Each cell has its own signal
class TableRow {
 final signal<String> name;
 final signal<double> weight;
 final signal<String> status;
 final signal<DateTime> updatedAt;

 TableRow({
 required String name,
 required double weight,
 required String status,
 required DateTime updatedAt,
 }) : name = signal(name),
 weight = signal(weight),
 status = signal(status),
 updatedAt = signal(updatedAt);
}

// Each cell is a Watch widget that reads one signal
Watch((context) => Text(row.name.value)) // Only this rebuilds

I measured the rebuild count using Flutter DevTools: editing a single cell in the Riverpod version triggered 4,000+ widget rebuilds (500 rows × 8 columns). The signals version triggered exactly 1 rebuild. On a mid-range Android device, that difference was visible — the Riverpod version had frame drops during edits, the signals version was instant. This kind of optimization matters a lot for apps targeting low-end hardware.

The Rebuild Math

Riverpod with select() can reduce the 4,000 rebuilds to ~500 (one per row). Signals reduce it to 1. You can optimize Riverpod to individual cells using family providers per cell, but that's 4,000 provider declarations for one table. Signals model this naturally — each cell is a signal, each cell widget watches its own signal. Zero architecture overhead for fine-grained updates.

Batch Updates and Untracked Reads

Two patterns I use daily that aren't obvious from the README:

batch() — when you need to update multiple signals but only want one notification cycle. Without batching, each signal update triggers its dependents immediately. With batching, all updates happen first, then dependents are notified once:

final firstName = signal('');
final lastName = signal('');
final age = signal(0);

// Without batch: 3 separate notification cycles
// Any computed or Watch reading these fires 3 times
firstName.value = 'Muhammad';
lastName.value = 'Shakil';
age.value = 28;

// With batch: 1 notification cycle
batch(() {
 firstName.value = 'Muhammad';
 lastName.value = 'Shakil';
 age.value = 28;
});
// Dependents notified once after all three updates

untracked() — read a signal's value without creating a dependency. I use this when a computed or effect needs to peek at a value without subscribing to its changes:

final searchQuery = signal('');
final analyticsEnabled = signal(true);

effect(() {
 final query = searchQuery.value; // Creates dependency — effect re-runs on change
 final enabled = untracked(() => analyticsEnabled.value); // No dependency

 if (enabled) {
 analytics.logSearch(query);
 }
});
// This effect re-runs when searchQuery changes
// but NOT when analyticsEnabled changes

Real App Patterns with Signals

Here's the architecture pattern I settled on after six months. It's not in any tutorial — I arrived at it through trial and error across three production apps:

// feature/cart/cart_signals.dart — One file per feature
import 'package:signals/signals.dart';

class CartSignals {
 // Core state
 final items = signal<List<CartItem>>([]);
 final promoCode = signal<String?>(null);

 // Derived state (computed)
 late final subtotal = computed(() =>
 items.value.fold(0.0, (sum, item) => sum + item.price * item.quantity));

 late final discount = computed(() {
 final code = promoCode.value;
 if (code == null) return 0.0;
 // Look up discount percentage from promo codes
 return _promoDiscounts[code] ?? 0.0;
 });

 late final total = computed(() =>
 subtotal.value * (1 - discount.value));

 late final itemCount = computed(() =>
 items.value.fold(0, (sum, item) => sum + item.quantity));

 late final isEmpty = computed(() => items.value.isEmpty);

 // Actions
 void addItem(CartItem item) {
 final existing = items.value.indexWhere((i) => i.id == item.id);
 if (existing != -1) {
 final updated = [...items.value];
 updated[existing] = updated[existing].copyWith(
 quantity: updated[existing].quantity + 1,
 );
 items.value = updated;
 } else {
 items.value = [...items.value, item];
 }
 }

 void removeItem(String id) {
 items.value = items.value.where((i) => i.id != id).toList();
 }

 void applyPromo(String code) => promoCode.value = code;
 void clearCart() {
 batch(() {
 items.value = [];
 promoCode.value = null;
 });
 }

 static const _promoDiscounts = {'SAVE10': 0.10, 'SAVE20': 0.20};
}

// Create one instance per scope (global, or injected per screen)
final cartSignals = CartSignals();

The pattern is: group related signals in a class, put computed derivations as late final fields, expose actions as methods, and create instances at the scope you need. For the cart above, it's a global singleton. For a form, I create an instance in the StatefulWidget's initState and dispose in dispose(). This gives me the organizational structure I missed when using bare signals — similar to how I'd organize notifiers in Riverpod 3 but without the provider declarations and code generation.

Testing Signals — The Easiest Part

Testing is where signals genuinely surprised me. No ProviderContainer, no BlocTest, no mock injection setup. Signals are Dart objects — create them, use them, assert:

import 'package:test/test.dart';
import 'package:signals/signals.dart';

void main() {
 group('CartSignals', () {
 late CartSignals cart;

 setUp(() => cart = CartSignals());

 test('addItem increases item count', () {
 cart.addItem(CartItem(id: '1', name: 'Widget', price: 9.99, quantity: 1));
 expect(cart.itemCount.value, 1);
 expect(cart.subtotal.value, 9.99);
 });

 test('addItem increments quantity for existing item', () {
 final item = CartItem(id: '1', name: 'Widget', price: 9.99, quantity: 1);
 cart.addItem(item);
 cart.addItem(item);
 expect(cart.items.value.length, 1); // Still one unique item
 expect(cart.items.value.first.quantity, 2); // Quantity increased
 expect(cart.subtotal.value, 19.98);
 });

 test('promo code applies discount', () {
 cart.addItem(CartItem(id: '1', name: 'Widget', price: 100.0, quantity: 1));
 cart.applyPromo('SAVE10');
 expect(cart.discount.value, 0.10);
 expect(cart.total.value, 90.0);
 });

 test('clearCart resets everything', () {
 cart.addItem(CartItem(id: '1', name: 'Widget', price: 9.99, quantity: 1));
 cart.applyPromo('SAVE10');
 cart.clearCart();
 expect(cart.isEmpty.value, true);
 expect(cart.total.value, 0.0);
 });
 });
}

Compare this to testing a Riverpod AsyncNotifier where you need a ProviderContainer with overrides, or a Bloc test where you need blocTest() with seed/act/expect. Signal tests are plain Dart. Create the object, call methods, assert values. My testing strategy for signal-based features runs about 3x faster than equivalent Riverpod tests because there's no container setup overhead per test.

Widget Testing with Signals

For widget tests, I set signal values before pumping and verify the UI matches. Since signals are global or injected plain objects, there's no ProviderScope wrapper needed. The test widget tree is simpler and the test runs faster. The only gotcha: reset signal values in setUp() to avoid test pollution from previous test cases.

Performance Numbers from My Three Apps

I tracked metrics during each migration. These numbers are from real production apps measured with Flutter DevTools memory view and frame rate monitoring on a Samsung Galaxy A13 (a budget phone I keep specifically for testing):

Metric Logistics Dashboard Inventory App Client Portal
Provider/Signal count 62 → 45 signals 38 → 28 signals 51 → 35 signals
Widget rebuilds (key screen) 4,200 → 180 890 → 120 1,500 → 95
Frame rate during edits 42fps → 60fps 55fps → 60fps 48fps → 59fps
Cold start time 2.1s → 1.8s 1.4s → 1.3s 1.9s → 1.6s
Peak memory (key screen) 142MB → 128MB 98MB → 91MB 115MB → 104MB
build_runner time (dev) 8-12s → 0s 5-8s → 0s 6-10s → 0s

The biggest win is the build_runner elimination. Zero code generation means zero waiting. The rebuild reduction is dramatic on data-heavy screens but modest on simple screens where Riverpod was already efficient. Cold start improvements come from not initializing a ProviderScope tree at app launch. For apps where startup performance matters — which is every app — see my performance optimization guide for more techniques.

Using Signals and Riverpod Together

My current recommendation for teams considering signals isn't "replace Riverpod" — it's "use both." Riverpod excels at things signals don't handle: dependency injection, provider scoping, lifecycle management with autoDispose, and service-layer architecture. Signals excel at UI-level reactivity. Here's the pattern I use in two production apps:

// Riverpod handles the service layer
@riverpod
ApiClient apiClient(Ref ref) => ApiClient(baseUrl: 'https://api.example.com');

@riverpod
AuthRepository authRepository(Ref ref) =>
 AuthRepository(ref.watch(apiClientProvider));

// Signals handle the feature-level UI state
class OrderDashboardSignals {
 OrderDashboardSignals(this._api);
 final ApiClient _api;

 final orders = signal<List<Order>>([]);
 final selectedFilter = signal('all');
 final searchQuery = signal('');

 late final filteredOrders = computed(() {
 var result = orders.value;
 final filter = selectedFilter.value;
 final query = searchQuery.value.toLowerCase();

 if (filter != 'all') {
 result = result.where((o) => o.status == filter).toList();
 }
 if (query.isNotEmpty) {
 result = result.where((o) =>
 o.customerName.toLowerCase().contains(query)).toList();
 }
 return result;
 });

 Future<void> loadOrders() async {
 orders.value = await _api.fetchOrders();
 }
}

// In the widget — bridge the two systems
class OrderDashboard extends ConsumerStatefulWidget {
 const OrderDashboard({super.key});

 @override
 ConsumerState<OrderDashboard> createState() => _OrderDashboardState();
}

class _OrderDashboardState extends ConsumerState<OrderDashboard> {
 late final OrderDashboardSignals signals;

 @override
 void initState() {
 super.initState();
 final api = ref.read(apiClientProvider);
 signals = OrderDashboardSignals(api);
 signals.loadOrders();
 }

 @override
 Widget build(BuildContext context) {
 return Column(
 children: [
 Watch((context) => Text('${signals.filteredOrders.value.length} orders')),
 Watch((context) => OrderList(orders: signals.filteredOrders.value)),
 ],
 );
 }
}

Riverpod injects the ApiClient. Signals manage the UI state. Each system does what it's best at. This hybrid approach gave me the architectural benefits of Riverpod for the clean architecture service layer while getting fine-grained reactivity for the UI. It also makes incremental migration possible — convert one feature at a time without touching the service layer.

When Signals Don't Fit

I've been positive about signals so far, so let me be honest about where they fall short. These are genuine problems I've encountered, not theoretical concerns:

No built-in lifecycle management. Riverpod's autoDispose automatically cleans up providers when no widget watches them. Signals don't have this. If you create a signal for a screen and forget to dispose it when the screen is popped, it stays in memory. I've had memory leaks from exactly this, caught during profiling with DevTools.

No dependency injection. Signals are plain objects — there's no provider tree to override in tests or swap implementations per flavor. I handle this with constructor injection (pass dependencies into signal classes) but it's manual work that Riverpod handles automatically.

Smaller ecosystem. If you Google a Riverpod problem, you'll find a Stack Overflow answer. If you Google a signals problem, you might find the GitHub issues page. The community is growing but it's still small. For a team that relies on tutorials and community support, this matters.

No server-side state management patterns. For apps that need offline sync, optimistic updates, or cache invalidation, Riverpod's AsyncNotifier with keepAlive and refresh() handles these patterns well. Signals don't have a built-in equivalent. I built my own refresh-on-focus pattern for the logistics app and it took a full day to get right. For offline-first apps, I'd still use Riverpod or Bloc for data synchronization and signals for the display layer.

My Decision Framework

Use signals when: you need fine-grained UI reactivity, your feature is self-contained, you want minimal boilerplate, and your team reads package source code comfortably. Use Riverpod when: you need DI, lifecycle management, async data caching, or a large team that benefits from enforced patterns. Use both when: you want the best of each, and your team can handle two mental models.

Migrating from Riverpod to Signals

If you decide to migrate, don't do it all at once (I'll say this for every migration, because I made this mistake with my Riverpod 3 migration too). Here's the approach I use:

  1. Add signals_flutter to pubspec.yaml alongside Riverpod — they coexist fine
  2. Pick one feature to migrate — choose something self-contained like a settings screen or a filter panel
  3. Create a signals class for that feature — group all the signals and computed values in one class, inject any Riverpod-provided dependencies through the constructor
  4. Replace ConsumerWidget with regular StatelessWidget + Watch — swap ref.watch() calls for Watch widgets reading signals
  5. Test the migrated feature independently — run unit tests (plain Dart, no container needed) and widget tests
  6. Keep Riverpod for the service layer — API clients, repositories, auth state should stay in Riverpod providers
# pubspec.yaml — both libraries coexist
dependencies:
 flutter_riverpod: ^3.0.0
 riverpod_annotation: ^3.0.0
 signals: ^6.0.0
 signals_flutter: ^6.0.0

I migrated the logistics dashboard one screen at a time over two weeks. The inventory app took five days. The client portal was done in three days because I'd built up the patterns by then. Each migration got faster because the signal class pattern I described above is reusable — once you have the template, it's mostly mechanical work. If you've done security audits on your app, the migration is also a good time to review how state flows through your architecture.

Common Mistakes I Made with Signals

These are lessons from breaking things in production, not from reading documentation:

Mistake 1: Mutating list items instead of replacing the list. Signals use == equality to detect changes. If you do items.value.add(newItem), the list reference doesn't change, so dependents aren't notified. Always create a new list: items.value = [...items.value, newItem].

Mistake 2: Creating signals inside build methods. If you create a signal inside a widget's build() method, every rebuild creates a new signal instance with a new set of dependencies. The old one is orphaned. Always create signals in initState(), in a class field, or at module scope.

Mistake 3: Not batching related updates. Setting three related signals without batch() causes three rebuild cycles. In a complex screen with computed chains, this can cause intermediary states where some values have updated and others haven't. Users see flickering or inconsistent data for a frame. Always batch() related mutations.

Mistake 4: Circular computed dependencies. If computed A reads signal B, and coded logic sets B based on A's value, you create an infinite loop. The signals package detects this and throws a runtime error, but it's still a bug. I hit this when I had a price computed that read a discount signal, and an effect that updated the discount based on the price. The fix was restructuring so computations flow in one direction.

Mistake 5: Using signals for async data fetching. Signals are synchronous values. For async operations, I initially tried wrapping Future results into signals, but the loading/error/data state management was garbage. Now I use futureSignal() from the package or keep async data in Riverpod's AsyncNotifier and expose the result as a signal for the UI. For a deeper look at async patterns, check how I handle them with Cloud Functions integration.

Where Signals Are Heading

The signals pattern is gaining momentum across the entire frontend ecosystem, not just Flutter. Angular 16+ has built-in signals. Svelte 5 runes are signals under the hood. SolidJS was built on signals from day one. In the Dart ecosystem, Rody Davis has been actively developing the package with regular releases handling edge cases and performance improvements.

My prediction: signals won't replace Riverpod or Bloc for most teams, but they'll become the default for UI-level reactivity in Flutter apps. The widget-level reactivity model is just too good to ignore once you've experienced it. I expect a future version of Flutter itself might adopt signal-like primitives — several Flutter team members have discussed fine-grained reactivity in talks and GitHub issues.

For now, I'm building all new features with the hybrid approach: Riverpod for the service/data layer, signals for the presentation layer. For new developers joining my team, I point them to the state management comparison first, then this post for the signals deep-dive. If they're working on an existing app, the Riverpod 3 migration guide comes first because that's what most of the codebase uses. The transition to signals is gradual, feature by feature, at a pace the team is comfortable with.

If you're building a Flutter startup app from scratch in 2026 and want the fastest development velocity with the least boilerplate, give signals a real try — not just a counter example, but a real feature with forms, API calls, computed state, and effects. You might not go back. And if you're working on something that needs push notifications or payment integration, know that signals coexist with every other library in the Flutter ecosystem. The only thing that changes is how your widgets observe state.

Frequently Asked Questions

What is the signals package in Flutter and who created it?

The signals package for Dart and Flutter was created by Rody Davis, a developer advocate at Google. It brings the signals reactive primitive from JavaScript frameworks like SolidJS and Preact into the Dart ecosystem. The core package is signals for Dart logic and signals_flutter for Flutter-specific bindings with the Watch widget integration. I've been using it in production for six months — the API is stable and well documented.

How are Signals different from Riverpod providers?

Signals provide fine-grained reactivity at the variable level. Riverpod provides reactivity at the provider level. With Riverpod, when a provider's state changes, every widget watching that provider rebuilds. With signals, only the specific Watch widget reading a changed signal rebuilds. Signals also have zero boilerplate — no provider declarations, no code generation, no ProviderScope. The tradeoff is Riverpod gives you dependency injection, scoping, and autoDispose lifecycle management that signals don't have built in. I wrote a full comparison here.

Should I replace Riverpod with Signals in my existing app?

Probably not for an existing app that already works well with Riverpod. I replaced Riverpod in three apps and the migration effort is real — each one took days to weeks. Signals shine in new projects or new features where you want minimal boilerplate and fine-grained rebuilds. For existing Riverpod apps, the better move is upgrading to Riverpod 3 with code generation rather than switching state management approaches entirely.

Can I use Signals and Riverpod together in the same app?

Yes and I actually recommend this approach. I use Riverpod for dependency injection and service-layer providers (API clients, repositories, auth) while using signals for UI-level reactive state within individual features. They don't conflict because they operate at different layers. A Riverpod provider can inject dependencies into a signals class through its constructor. This hybrid approach gives me the best of both worlds.

Are Signals production-ready for large Flutter apps?

The signals package is stable and well-tested. I've been running it in three production apps for six months with zero stability issues or crashes. The ecosystem around it is smaller than Riverpod or Bloc though — fewer tutorials, fewer Stack Overflow answers, fewer third-party integrations. For a team comfortable reading package source code and solving problems independently, signals work great in production. For a team that relies on community support, I'd wait another six months for the ecosystem to mature.

How do computed signals prevent unnecessary rebuilds?

A computed signal only recalculates when a source signal it reads actually changes value. If the computed result is the same as before (by == equality), dependents aren't even notified. This two-layer caching — skip computation if inputs unchanged, skip notification if output unchanged — is what gives signals their performance edge. In my logistics dashboard, a filter computed that reads a search query, a date range, and a status filter only recomputes when one of those three changes, and only triggers a rebuild if the filtered list is actually different.

What is the effect function in signals used for?

Effect runs a callback whenever any signal it reads changes. I use it for side effects that shouldn't cause UI rebuilds: persisting to SharedPreferences, sending analytics events, logging state changes, or triggering animations. Think of it like ref.listen() in Riverpod but without needing a provider. The critical rule is never modify a signal's value inside an effect that reads that same signal — that creates an infinite loop. Effects should be read-only reactions.

How do I test signals in Flutter?

Signals are plain Dart objects, so unit testing is straightforward: create a signal, call your logic, assert the signal's .value. No ProviderContainer or mock setup needed. For widget tests, wrap your widget tree normally (no ProviderScope) and verify that Watch widgets display the correct data. I test computed chains by setting source signals and asserting the computed output. Reset all signals in setUp() to avoid test pollution between test cases. The simplicity of testing is one of the biggest practical advantages I've found over any provider-based solution.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.