flutter_hooks
flutter_hooks copied to clipboard
useState isnt triggerring rerenders when changed in a use effect
Describe the bug useState seems to not trigger a rerender if given a new value in a useEffect
To Reproduce
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
Future<num> asyncSquare(int base) {
return Future.delayed(const Duration(seconds: 1), () => pow(base, 2),);
}
AsyncSnapshot<num> useSquare(int base) {
debugPrint("useSquare ran base: $base");
final squareFuture = useState(asyncSquare(base));
final result = useFuture(squareFuture.value);
useEffect(() {
debugPrint("effect triggered base: $base");
final newFuture = asyncSquare(base);
if (newFuture != squareFuture.value) {
debugPrint("new future doesnt have equality");
}
squareFuture.value = newFuture;
return null;
}, [base]);
return result;
}
class Reproduction extends HookWidget {
const Reproduction({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final squareInput = useState(2);
final square = useSquare(squareInput.value);
return Column(children: [Text("square: ${square.data}"), TextButton(onPressed: () {squareInput.value = 5;}, child: Text("change"))]);
}
}
Expected behavior In the example I'd expect pressing the button to cause the text to change to 25 after a second
Actual behaviour It stays on 4
logs:
flutter: useSquare ran base: 2
flutter: effect triggered base: 2
flutter: new future doesnt have equality
flutter: useSquare ran base: 2
flutter: useSquare ran base: 2
flutter: useSquare ran base: 5
flutter: effect triggered base: 5
flutter: new future doesnt have equality
made a reproduction not involving futures
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class Reproduction extends HookWidget {
const Reproduction({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final squareInput = useState(2);
final badlyMemoizedSquare = useState<num?>(null);
final isEven = useState(false);
useEffect(() {
isEven.value = (badlyMemoizedSquare.value ?? 0) % 2 == 0;
return null;
}, [badlyMemoizedSquare.value]);
useEffect(() {
debugPrint("effect triggered base: ${squareInput.value}");
badlyMemoizedSquare.value = pow(squareInput.value, 2);
return null;
}, [squareInput.value]);
return Column(children: [Text("square: ${badlyMemoizedSquare.value} is ${isEven.value ? "even" : "odd"}"), TextButton(onPressed: () {squareInput.value += 1;}, child: Text("increase base"))]);
}
}
also found a workaround for now:
Future.delayed(Duration.zero, () async {
useStateResult.value = newFuture;
});
scheduling the change to run like this means its executed after the current render which means the useState correctly causes a re-render
I assume it's because you'll need to mutate the useState value inside a WidgetsBinding.instance.addPostFrameCallback((timeStamp) { });
made a reproduction not involving futures
What's supposed to happen? Why would your widget rebuild?
The newer reproduction should count through square numbers and say if they are even or not instead it counts through square numbers and says if the last one was even or not. If this bug was in react you just wouldn't see the numbers change on the first button press as calling set state doesn't mutate the current state value but a value inside the hook and triggers a rerender. But this implementation you can mutate the result of the hook during a render which is why it doesn't show up in most cases
I think the difference is in react useEffect schedules the function to be run after the frame whereas this implementation does it during the current frame.
if the objective is a flutter implementation of react hooks should effects be changed to run after the frame like they would in react? I know that would probably be a breaking change
useEffect in flutter_hooks is synchronous, to mimic how most Flutter life-cycles are synchronous too.
I have no plan to change this for now. You're free to wrap the state change in a Future(() => state.value = ... to work around this