Created
September 25, 2025 19:14
-
-
Save dickermoshe/45d44549980d1a84f05dd7a8a344987a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'package:flutter/material.dart'; | |
| import 'dart:async'; | |
| import 'package:signals_flutter/signals_flutter.dart'; | |
| import 'package:flutter_hooks/flutter_hooks.dart'; | |
| void main() { | |
| runApp(const MyApp()); | |
| } | |
| Future<int> fetchUser(int id) async { | |
| return id * id; | |
| } | |
| class MyApp extends StatelessWidget { | |
| const MyApp({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return MaterialApp( | |
| debugShowCheckedModeBanner: false, | |
| home: Scaffold( | |
| body: Center( | |
| child: HookBuilder( | |
| builder: (context) { | |
| final numberSignal = useSignal(1); | |
| final requestCounter = useSignal(1); | |
| final squaredSignal = useMemoized(() { | |
| final inputSignal = computed( | |
| () => | |
| (number: numberSignal.value, count: requestCounter.value), | |
| ); | |
| return ComputedFuture.withSignal(inputSignal, ( | |
| state, | |
| input, | |
| ) async { | |
| // Debouncing | |
| await Future.delayed(Duration(milliseconds: 100)); | |
| if (state.isCanceled) { | |
| throw Exception(); | |
| } | |
| await Future.delayed(Duration(seconds: 1)); | |
| return input.number * 2; | |
| }); | |
| }); | |
| useExistingSignal(squaredSignal); | |
| return Column( | |
| mainAxisSize: MainAxisSize.min, | |
| children: [ | |
| Text(squaredSignal.value.toString()), | |
| Text(squaredSignal.value.value.toString()), | |
| ElevatedButton( | |
| child: Text("Refresh"), | |
| onPressed: () { | |
| numberSignal.value = numberSignal.value + 1; | |
| }, | |
| ), | |
| ], | |
| ); | |
| }, | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| /// COPIED FROM SIGNALS_HOOKS | |
| Signal<T> useSignal<T>(T value, {List<Object?> keys = const <Object>[]}) { | |
| final s = useMemoized(() => signal(value), keys); | |
| return useExistingSignal(s, keys: keys); | |
| } | |
| S useExistingSignal<T, S extends ReadonlySignal<T>>( | |
| S value, { | |
| List<Object?> keys = const <Object>[], | |
| }) { | |
| return use(SignalHook<T, S>(value, keys: keys)); | |
| } | |
| class SignalHook<T, S extends ReadonlySignal<T>> extends Hook<S> { | |
| const SignalHook(this.target, {super.keys}); | |
| final S target; | |
| @override | |
| SignalHookState<T, S> createState() => SignalHookState(); | |
| } | |
| class SignalHookState<T, S extends ReadonlySignal<T>> | |
| extends HookState<S, SignalHook<T, S>> { | |
| void Function()? _cleanup; | |
| @override | |
| void initHook() { | |
| _listener(); | |
| super.initHook(); | |
| } | |
| @override | |
| void dispose() { | |
| _cleanup?.call(); | |
| } | |
| void _listener() { | |
| _cleanup = hook.target.subscribe((_) { | |
| if (context.mounted) setState(() {}); | |
| }); | |
| } | |
| @override | |
| void didUpdateHook(SignalHook<T, S> oldHook) { | |
| super.didUpdateHook(oldHook); | |
| if (hook.target != oldHook.target) { | |
| _cleanup?.call(); | |
| _listener(); | |
| } | |
| } | |
| @override | |
| S build(BuildContext context) => hook.target; | |
| @override | |
| Object? get debugValue => hook.target.value; | |
| @override | |
| String get debugLabel => '${hook.target.runtimeType}'; | |
| } | |
| /// Represents the state of an async computation that can be canceled. | |
| /// | |
| /// This class manages the lifecycle of async operations, allowing them to be | |
| /// canceled and providing a way to chain to new computations. | |
| /// | |
| /// ```dart | |
| /// // Debounced search with HTTP request cancellation | |
| /// final searchQuery = signal(''); | |
| /// final searchSignal = ComputedAsync(searchQuery, (state, query) async { | |
| /// // Debounce: wait 300ms before making request | |
| /// await Future.delayed(Duration(milliseconds: 300)); | |
| /// | |
| /// // If the user continued typing in the past 300ms, this | |
| /// // computation will have been canceled. Check if that's the case | |
| /// if (state.isCanceled) throw Exception('Search canceled'); | |
| /// | |
| /// // Add a callback which will abort the request if the state is canceled | |
| /// final cancelToken = CancelToken(); | |
| /// state.onCancel(() { | |
| /// cancelToken.cancel(); | |
| /// }); | |
| /// return await client.get('https://api.example.com', cancelToken: cancelToken); | |
| /// }); | |
| /// ``` | |
| class FutureState<O> { | |
| FutureState._(); | |
| /// Returns true if this async state has been canceled. | |
| bool get isCanceled => __isCanceled; | |
| bool __isCanceled = false; | |
| late Function() __cancelFn = () => __isCanceled = true; | |
| /// Cancel the async state | |
| /// If a new state is provided, the future will be replaced with the new state's future | |
| void _cancel(FutureState<O>? newState) { | |
| if (!isCanceled) { | |
| __nextCompleter = newState?.__completer; | |
| __cancelFn(); | |
| } | |
| } | |
| /// Adds a cancel callback to be executed when this async state is canceled. | |
| /// | |
| /// The callback will be called in addition to the default cancel behavior. | |
| /// Multiple callbacks can be added and will be executed in the order they were added. | |
| void onCancel(Function newOnCancel) { | |
| final prevOnCancel = __cancelFn; | |
| __cancelFn = () { | |
| prevOnCancel(); | |
| newOnCancel(); | |
| }; | |
| } | |
| final __completer = Completer<O>(); | |
| Completer<O>? __nextCompleter; | |
| Future<O> get _future async { | |
| try { | |
| final result = await __completer.future; | |
| if (__nextCompleter != null) { | |
| return __nextCompleter!.future; | |
| } | |
| return result; | |
| } catch (e) { | |
| if (isCanceled && __nextCompleter != null) { | |
| return __nextCompleter!.future; | |
| } | |
| rethrow; | |
| } | |
| } | |
| } | |
| class _TrackedComputed<T> extends Computed<T> with TrackedSignalMixin<T> { | |
| _TrackedComputed(super.fn, {super.debugLabel, super.autoDispose}); | |
| } | |
| class ComputedFuture<O, I> extends FutureSignal<O> | |
| with TrackedSignalMixin<AsyncState<O>> { | |
| ComputedFuture._( | |
| this._stateSignal, | |
| super.fn, { | |
| super.debugLabel, | |
| super.autoDispose, | |
| super.lazy, | |
| super.initialValue, | |
| }); | |
| late final Computed<FutureState<O>> _stateSignal; | |
| @override | |
| /// A future which will complete with the result of this computation. | |
| /// | |
| /// This should not be called outside of another `ComputedFuture`. | |
| /// Awaiting this future elsewhere will have undefined behavior. | |
| Future<O> get future { | |
| return _stateSignal.value._future; | |
| } | |
| @override | |
| /// The current state of this computation. Fetching this value outside of an effect/computed will result undefined behavior. | |
| AsyncState<O> get value => super.value; | |
| factory ComputedFuture( | |
| Future<O> Function(FutureState state) fn, { | |
| String? debugLabel, | |
| bool autoDispose = false, | |
| bool lazy = true, | |
| }) { | |
| // A computed signal that will provide a new state whenever the input changes | |
| final stateSignal = _TrackedComputed(() { | |
| final state = FutureState<O>._(); | |
| void inner() async { | |
| try { | |
| state.__completer.complete(await fn(state)); | |
| } catch (e, s) { | |
| state.__completer.completeError(ComputedFutureException._(e, s), s); | |
| } | |
| } | |
| inner(); | |
| return state; | |
| }, autoDispose: autoDispose); | |
| // Setup the cancellation when the signal is disposed | |
| // NOTE: If a user is listening to a future outside of an effect/computed, | |
| // and this future is canceled (e.g. a cancel token is canceled), | |
| // this will throw an exception. This is a slight footgun that should be documented. | |
| stateSignal.onDispose(() { | |
| stateSignal.value._cancel(null); | |
| }); | |
| // Return the computed future, whenever inputs change, the previous state will be canceled. | |
| return ComputedFuture._( | |
| stateSignal, | |
| () { | |
| final currentState = stateSignal.value; | |
| stateSignal.previousValue?._cancel(currentState); | |
| return currentState._future; | |
| }, | |
| debugLabel: debugLabel, | |
| autoDispose: autoDispose, | |
| lazy: lazy, | |
| ); | |
| } | |
| factory ComputedFuture.withSignal( | |
| ReadonlySignal<I> input, | |
| Future<O> Function(FutureState state, I input) fn, { | |
| String? debugLabel, | |
| bool autoDispose = false, | |
| bool lazy = true, | |
| }) { | |
| // A computed signal that will provide a new state whenever the input changes | |
| final stateSignal = _TrackedComputed(() { | |
| input.value; // Subscribe to the input | |
| final state = FutureState<O>._(); | |
| void inner() async { | |
| try { | |
| state.__completer.complete(await fn(state, input.value)); | |
| } catch (e, s) { | |
| state.__completer.completeError(ComputedFutureException._(e, s), s); | |
| } | |
| } | |
| inner(); | |
| return state; | |
| }, autoDispose: autoDispose); | |
| // Setup the cancellation when the signal is disposed | |
| // NOTE: If a user is listening to a future outside of an effect/computed, | |
| // and this future is canceled (e.g. a cancel token is canceled), | |
| // this will throw an exception. This is a slight footgun that should be documented. | |
| stateSignal.onDispose(() { | |
| stateSignal.value._cancel(null); | |
| }); | |
| // Return the computed future, whenever inputs change, the previous state will be canceled. | |
| return ComputedFuture._( | |
| stateSignal, | |
| () { | |
| final currentState = stateSignal.value; | |
| stateSignal.previousValue?._cancel(currentState); | |
| return currentState._future; | |
| }, | |
| debugLabel: debugLabel, | |
| autoDispose: autoDispose, | |
| lazy: lazy, | |
| ); | |
| } | |
| } | |
| /// An exception thrown when trying to access an async signal that is in an error state. | |
| /// | |
| /// This is typically thrown when a [ComputedFuture] encounters an error during computation | |
| /// or when awaiting the [future] property of a failed async signal. | |
| /// | |
| /// ```dart | |
| /// try { | |
| /// final result = await myAsyncSignal.future; | |
| /// } on ComputedFutureException catch (e) { | |
| /// print('Error: ${e.exception}'); | |
| /// } | |
| /// ``` | |
| final class ComputedFutureException implements Exception { | |
| ComputedFutureException._(this.exception, this.stackTrace); | |
| /// The original exception that caused the async signal to fail. | |
| final Object exception; | |
| /// The stack trace of the original exception. | |
| final StackTrace stackTrace; | |
| /// Returns a string representation of this exception. | |
| @override | |
| String toString() { | |
| if (exception case final ComputedFutureException exception) { | |
| return ''' | |
| $exception | |
| And rethrown at: | |
| $stackTrace'''; | |
| } | |
| return ''' | |
| ComputedFutureException: Tried to use a ComputedFuture that is in error state. | |
| A ComputedFuture threw the following exception: | |
| $exception | |
| The stack trace of the exception: | |
| $stackTrace'''; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment