주요 콘텐츠로 건너뛰기

훅(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');
},
);
}
}