훅(hook)에 대하여
이 페이지에서는 훅(hook)이 무엇이고 Riverpod과 어떤 관계인지 설명합니다.
"훅"은 Riverpod과는 별개인 패키지 flutter_hooks에서 제공하는 유틸리티입니다.
flutter_hooks는 완전히 독립된 패키지이며 (적어도 직접적으로는) Riverpod과 아무 관련이 없지만,
Riverpod과 flutter_hooks를 함께 사용하는 경우가 많습니다.
훅을 사용해야 할까요?
훅은 강력한 도구이지만, 모두에게 맞는 것은 아닙니다.
Riverpod을 처음 접한다면 훅은 사용하지 마세요.
훅이 유용하긴 하지만, Riverpod에 꼭 필요한 것은 아닙니다.
Riverpod 때문에 훅을 쓰기 시작해서는 안 됩니다. 훅을 쓰고 싶어서
쓰기 시작하는 것이어야 합니다.
훅을 사용하는 데는 장단점이 있습니다. 견고하고 재사용 가능한 코드를 만드는 데 큰 도움이 되지만, 새로 배워야 하는 개념이고 처음에는 헷갈릴 수 있습니다. 훅은 Flutter의 핵심 개념이 아닙니다. 그래서 Flutter/Dart에서는 다소 이질적으로 느껴질 수 있습니다.
훅이란?
훅은 위젯 안에서 사용하는 함수입니다. 로직을 더 쉽게 재사용하고 조합할 수 있도록 StatefulWidget의 대안으로 설계되었습니다.
훅은 React에서 온 개념이며, flutter_hooks는
React의 구현을 Flutter로 옮긴 것에 불과합니다.
그래서 실제로 훅이 Flutter에서는 조금 어색하게 느껴질 수 있습니다. 이상적으로는
언젠가 훅이 해결하는 문제를 Flutter에 맞게 설계된 방식으로 해결할 수 있으면 좋을 것입니다.
Riverpod의 provider가 "전역" 애플리케이션 상태를 위한 것이라면, 훅은
위젯의 로컬 상태를 위한 것입니다. 훅은 주로
TextEditingController,
AnimationController 같은
상태를 가진 UI 객체를 다룰 때 사용합니다.
또한 "builder" 패턴을 대체할 수도 있습니다. FutureBuilder/TweenAnimatedBuilder 같은
위젯을 "중첩" 없는 방식으로 바꿔 주므로 가독성이 크게 좋아집니다.
일반적으로 훅은 다음과 같은 경우에 유용합니다.
- 폼
- 애니메이션
- 사용자 이벤트에 반응하기
- 등
예를 들어 훅을 사용해 페이드인 애니메이션을 직접 구현할 수 있습니다. 위젯이 처음에는 보이지 않다가 서서히 나타나는 애니메이션입니다.
StatefulWidget을 사용하면 코드는 다음과 같습니다.
class FadeIn extends StatefulWidget {
const FadeIn({Key? key, required this.child}) : super(key: key);
final Widget child;
State<FadeIn> createState() => _FadeInState();
}
class _FadeInState extends State<FadeIn> with SingleTickerProviderStateMixin {
late final AnimationController animationController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
void initState() {
super.initState();
animationController.forward();
}
void dispose() {
animationController.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animationController,
builder: (context, child) {
return Opacity(
opacity: animationController.value,
child: widget.child,
);
},
);
}
}
훅을 사용하면 같은 동작을 다음과 같이 작성할 수 있습니다.
class FadeIn extends HookWidget {
const FadeIn({Key? key, required this.child}) : super(key: key);
final Widget child;
Widget build(BuildContext context) {
// AnimationController를 생성합니다. 이 컨트롤러는 위젯이 언마운트될 때
// 자동으로 폐기됩니다.
final animationController = useAnimationController(
duration: const Duration(seconds: 2),
);
// useEffect는 initState + didUpdateWidget + dispose에 해당합니다.
// useEffect에 전달한 콜백은 훅이 처음 호출될 때 실행되고,
// 이후 두 번째 매개변수로 전달한 리스트가 바뀔 때마다 실행됩니다.
// 여기서는 빈 const 리스트를 전달하므로 `initState`와 정확히 같습니다.
useEffect(() {
// 위젯이 처음 렌더링될 때 애니메이션을 시작합니다.
animationController.forward();
// 필요하다면 여기서 "dispose" 로직을 반환할 수 있습니다
return null;
}, const []);
// 애니메이션이 갱신될 때 이 위젯을 다시 빌드하도록 Flutter에 알립니다.
// AnimatedBuilder에 해당합니다
useAnimation(animationController);
return Opacity(
opacity: animationController.value,
child: child,
);
}
}
이 코드에서 눈여겨볼 점이 몇 가지 있습니다.
메모리 누수가 없습니다. 이 코드는 위젯이 다시 빌드될 때마다 새
AnimationController를 만들지 않으며, 위젯이 언마운트되면 컨트롤러가 올바르게 해제됩니다.한 위젯 안에서 훅을 원하는 만큼 사용할 수 있습니다. 따라서 필요하다면
AnimationController를 여러 개 만들 수 있습니다.
Widget build(BuildContext context) {
final animationController = useAnimationController(
duration: const Duration(seconds: 2),
);
final anotherController = useAnimationController(
duration: const Duration(seconds: 2),
);
...
}이렇게 하면 아무 문제 없이 컨트롤러 두 개가 만들어집니다.
원한다면 이 로직을 재사용 가능한 별도 함수로 분리할 수도 있습니다.
double useFadeIn() {
final animationController = useAnimationController(
duration: const Duration(seconds: 2),
);
useEffect(() {
animationController.forward();
return null;
}, const []);
useAnimation(animationController);
return animationController.value;
}그러면 위젯이 HookWidget이기만 하면 이 함수를 위젯 안에서 사용할 수 있습니다.
class FadeIn extends HookWidget {
const FadeIn({Key? key, required this.child}) : super(key: key);
final Widget child;
Widget build(BuildContext context) {
final fade = useFadeIn();
return Opacity(opacity: fade, child: child);
}
}useFadeIn함수가FadeIn위젯과 완전히 독립적이라는 점에 주목하세요.
원한다면useFadeIn함수를 전혀 다른 위젯에서 사용해도 그대로 동작합니다!
훅의 규칙
훅에는 고유한 제약이 있습니다.
HookWidget을 상속한 위젯의
build메서드 안에서만 사용할 수 있습니다.좋은 예:
class Example extends HookWidget {
Widget build(BuildContext context) {
final controller = useAnimationController();
...
}
}나쁜 예:
// HookWidget이 아닙니다
class Example extends StatelessWidget {
Widget build(BuildContext context) {
final controller = useAnimationController();
...
}
}나쁜 예:
class Example extends HookWidget {
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
// _실제로는_ "build" 메서드 안이 아니라
// 사용자 상호작용 생명주기(여기서는 "on pressed") 안입니다.
final controller = useAnimationController();
},
child: Text('click me'),
);
}
}조건문이나 반복문 안에서는 사용할 수 없습니다.
나쁜 예:
class Example extends HookWidget {
const Example({required this.condition, super.key});
final bool condition;
Widget build(BuildContext context) {
if (condition) {
// 훅은 "if"/"for" 등의 안에서 사용하면 안 됩니다
final controller = useAnimationController();
}
...
}
}
훅에 대한 자세한 내용은 flutter_hooks를 참고하세요.
훅과 Riverpod
설치
훅은 Riverpod과 독립적이므로 별도로 설치해야 합니다. 훅을 사용하려면 hooks_riverpod만 설치해서는 부족하고, flutter_hooks도 의존성에 추가해야 합니다. 자세한 내용은 시작하기를 참고하세요.
사용법
훅과 Riverpod을 모두 사용하는 위젯을 작성하고 싶을 때가 있습니다.
그런데 이미 눈치채셨겠지만, 훅과 Riverpod은 각자 고유한 위젯 기본 타입인
HookWidget과 ConsumerWidget을 제공합니다.
하지만 클래스는 한 번에 하나의 슈퍼클래스만 상속할 수 있습니다.
이 문제를 해결하려면 hooks_riverpod 패키지를 사용하면 됩니다.
이 패키지는 HookWidget과 ConsumerWidget을 하나로 합친
HookConsumerWidget 클래스를 제공합니다.
따라서 HookWidget 대신 HookConsumerWidget을 상속하면 됩니다.
// We extend HookConsumerWidget instead of HookWidget
class Example extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
// We can use both hooks and providers here
final counter = useState(0);
final value = ref.watch(myProvider);
return Text('Hello $counter $value');
}
}
또는 두 패키지가 각각 제공하는 "builder"를 사용할 수도 있습니다.
예를 들어 StatelessWidget을 그대로 사용하면서
HookBuilder와 Consumer를 함께 쓸 수 있습니다.
class Example extends StatelessWidget {
Widget build(BuildContext context) {
// We can use the builders provided by both packages
return Consumer(
builder: (context, ref, child) {
return HookBuilder(
builder: (context) {
final counter = useState(0);
final value = ref.watch(myProvider);
return Text('Hello $counter $value');
},
);
},
);
}
}
이 방식은 hooks_riverpod 없이도 동작합니다. flutter_riverpod만 있으면 됩니다.
이 방식이 마음에 든다면, hooks_riverpod가 제공하는 HookConsumer를 사용해 더 간결하게 작성할 수 있습니다. HookConsumer는 두 builder를 하나로 합친 것입니다.
class Example extends StatelessWidget {
Widget build(BuildContext context) {
// Equivalent to using both Consumer and HookBuilder.
return HookConsumer(
builder: (context, ref, child) {
final counter = useState(0);
final value = ref.watch(myProvider);
return Text('Hello $counter $value');
},
);
}
}