[Flutter] flutter_hooks, 그게 뭔데?

Cody Yun
Flutter Seoul
Published in
28 min readJul 4, 2024

이번 포스팅에서는 유용성에 비해 많이 사용 되지 않는 flutter_hooks에 대해서 살펴보겠습니다. 앞서 flutter_hooks와 관련된 포스팅을 작성 했었습니다.

개념이 어렵단 생각에 useAnimationController, useTextEditController에 한해 제한적으로 사용해왔습니다. 주변을 개발자들을 살펴보면 flutter_hooks를 적극적으로 사용하는 개발자와 뭐가 좋은지 모르거나 어려워 도입을 꺼라혀는 개발자로 나눠집니다. 전자는 리액트 경험이 있었던 개발자들이 많았고, 후자는 그렇지 못한 경우가 많았습니다. 이번 포스팅에서는 리액트 경험이 없는 개발자 대상으로 실무적인 코드와 함께 flutter_hooks에 대해서 알아보겠습니다.

TextField에 입력된 텍스트에 따라 활성 상태가 바뀌는 Button

플러터 프로젝트를 하다보면 자주 접하는 요구사항 하나를 상상해 봅시다. TextField와 Button이 나란히 배치되어 있고, TextField에 입력된 텍스트에 따라 Button의 활성 상태가 변경되야 합니다. 플러터 선언형 UI 덕분에 레이아웃을 간단히 구현할 수 있습니다.

import 'package:flutter/material.dart';

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

@override
Widget build(BuildContext context) {
return Container(
color: Colors.grey[300],
padding: const EdgeInsets.all(8),
child: Row(
children: [
const Expanded(
child: TextField(
decoration: InputDecoration(hintText: 'Enter your message'),
),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: () {},
child: const Text('Submit'),
),
],
),
);
}
}

Container > Row > [Expanded > TextField, SizedBox, ElevatedButton > Text] 구조로 TextField와 ElevatedButton을 나란히 배치했습니다. 레이아웃은 완성했으니 TextField의 입력 상태에 따라 ElevatedButton의 활성 상태가 변경되도록 구현해봅시다. 흔히 봤던 예제코드를 떠올리며 아래와 같은 절차를 따라 구현을 하게됩니다.

  1. StatelessWidget을 StatefulWidget으로 변경
  2. StatefulWidget의 State에 TextEditingController 프로퍼티 추가
  3. State의 initState에서 TextEditingController의 addListener 메소드를 호출하며, TextField의 입력 상태가 변경될 때 마다 호출될 메소드 등록
  4. State의 dispose에서 TextEditingController의 removeListener와 dispose 호출해 메모리 누수 방지
  5. addListener로 TextField의 입력 상태가 변경될 때 호출되는 메소드에서 setState 호출해 리빌드
  6. build 메소드에서 TextField의 controller에 TextEditingController 객체의 인스턴스 전달
  7. ElevatedButton의 onPressed에서는 TextEditingController의 text 프로퍼티가 isEmpty이면 null을 전달하고, isEmpty가 아니면 버튼이 터치되면 호출될 메소드를 전달

위 과정을 거쳐 변경된 코드는 아래와 같습니다.

import 'package:flutter/material.dart';

class ChatInputStateful extends StatefulWidget {
const ChatInputStateful({super.key});

@override
State<ChatInputStateful> createState() => _ChatInputStatefulState();
}

class _ChatInputStatefulState extends State<ChatInputStateful> {
final TextEditingController textEditController = TextEditingController();
@override
void initState() {
super.initState();
textEditController.addListener(_onChangedText);
}

@override
void dispose() {
textEditController.removeListener(_onChangedText);
textEditController.dispose();
super.dispose();
}

_onChangedText() {
setState(() {});
}

@override
Widget build(BuildContext context) {
var isEmpty = textEditController.text.isEmpty;
return Container(
color: Colors.grey[300],
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: textEditController,
decoration: const InputDecoration(hintText: 'Enter your message'),
),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: isEmpty ? null : () => textEditController.clear(),
child: const Text('Submit'),
),
],
),
);
}
}

StatefulWidget의 문제를 일부 해결하는 mixin

구현이 어렵진 않지만 StatefulWidget과 State, setState를 활용하는 방식은 몇가지 문제가 있습니다.

  1. 간단한 동작을 위해 작성해야하는 코드(약 20줄 추가)
  2. 재사용 불가능한 로직(State의 라이프 사이클에 의존적인 코드)

flutter_hooks를 만든 Remi Rousselet은 flutter_hooks 깃헙의 README.md에 이렇게 소개하고 있습니다.

StatefulWidget은 큰 문제가 있습니다. initState와 dispose의 로직은 재 사용하기 매우 어렵습니다. (중략) 다트 믹스인은 이 문제를 부분적으로 해결하지만 mixin 끼리의 네이밍 충돌 등의 문제를 발생 시킵니다.

그렇다면 mixin으로 StatefulWidget의 문제점을 진짜 해결이 불가능한지 살펴봅시다.

mixin class TextEditControllerMixin {
final TextEditingController textEditController = TextEditingController();

void addTextEditControllerdListener(Function() onChangedText) {
textEditController.addListener(onChangedText);
}

void disposeTextEditController(Function() onChangedText) {
textEditController.removeListener(onChangedText);
textEditController.dispose();
}
}

class ChatInputMixin extends StatefulWidget {
const ChatInputMixin({super.key});

@override
State<ChatInputMixin> createState() => _ChatInputMixinState();
}

class _ChatInputMixinState extends State<ChatInputMixin>
with TextEditControllerMixin {
@override
void initState() {
super.initState();
addTextEditControllerdListener(_onChangedText);
}

@override
void dispose() {
disposeTextEditController(_onChangedText);
super.dispose();
}

_onChangedText() {
setState(() {});
}

@override
Widget build(BuildContext context) {
var isEmpty = textEditController.text.isEmpty;
return Container(
color: Colors.grey[300],
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: textEditController,
decoration: const InputDecoration(hintText: 'Enter your message'),
),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: isEmpty ? null : () => textEditController.clear(),
child: const Text('Submit'),
),
],
),
);
}
}

TextEditControllerMixin 이라는 이름의 믹스인 클래스를 선언합니다. TextEditingController를 프로퍼티로 추가하고, initState, dispose에서 호출할 메소드를 구현합니다. TextEditControllerMixin에서는 setState를 호출할 수 없기 때문에 TextField의 입력 상태가 변경되면 호출될 메소드를 믹스인 클래스의 메소드의 파라미터로 전달합니다. addListener와 removeListener, dispose를 호출하는 로직을 믹스인 클래스의 메소드로 옮겼기 때문에 재사용이 가능해졌습니다. 하지만 믹스인 클래스의 메소드는 여전히 State의 라이프 사이클에 의존적이기 때문에 initState, dispose에서 믹스인 클래스의 메소드를 호출해야 합니다. 결론적으로 코드량이 줄지 않았습니다.

StatefulWidget의 문제를 해결하는 flutter_hooks

StatefulWidget의 문제는 State의 라이프 사이클에 의존적인 코드에 의해 발생합니다. 로직이 라이프 사이클에 의존적인 이상 이러한 문제를 해결하긴 어렵습니다. 다시 flutter_hooks 깃헙 대문을 살펴봅시다. Remi Rousselet은 이렇게 설명하고 있습니다.

리액트훅을 플러터 기반으로 구현했습니다. 훅은 위젯의 라이프사이클을 관리하는 새로운 종류의 객체입니다. 중복 코드를 분리해 코드 재사용이 가능하도록 만드는 단 하나의 이유로 존재합니다.

StatefulWidget을 HooksWidget으로 변경하기

StatefulWidget을 HooksWidget으로 변경해봅시다. 더 이상 initState, dispose는 사용할 수 없습니다. 게다가 StatefulWidget 처럼 TextEditingController를 프로퍼티로 선언하면 const를 사용할 수 없습니다. 이러한 부분이 리액트 경험이 없는 플러터 개발자에게 flutter_hooks 도입을 난해하게 만드는 지점입니다.

class ChatInputHooks extends HookWidget {
final TextEditingController textEditController = TextEditingController();
ChatInputHooks({super.key});

@override
Widget build(BuildContext context) {
return Container(
color: Colors.grey[300],
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: textEditController,
decoration: const InputDecoration(hintText: 'Enter your message'),
),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: textEditController.text.isEmpty
? null
: () => textEditController.clear(),
child: const Text('Submit'),
),
],
),
);
}
}

다시 flutter_hooks를 설명하는 글을 살펴봅시다.

리액트훅을 플러터 기반으로 구현했습니다. 훅은 위젯의 라이프 사이클을 관리하는 새로운 종류의 객체입니다. 중복 코드를 분리해 코드 재사용이 가능하도록 만드는 단 하나의 이유로 존재합니다.

라이프사이클을 관리하는 새로운 종류의 객체가 flutter_hooks를 이해하는데 핵심적인 설명입니다. 라이프 사이클을 관리하는 새로운 종류의 객체라는게 리액트의 함수형 컴포넌트에 익숙한 개발자라면 쉽게 이해되지만, 플러터 개발자에겐 난해합니다. TextEditingController의 라이프 사이클을 관리하는 useTextEditingController를 사용해 StatefulWidget의 문제를 해결해봅시다.

class ChatInputHooks extends HookWidget {
const ChatInputHooks({super.key});

@override
Widget build(BuildContext context) {
final textEditingController = useTextEditingController();
return Container(
color: Colors.grey[300],
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: textEditingController,
decoration: const InputDecoration(hintText: 'Enter your message'),
),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: textEditingController.text.isEmpty
? null
: () => textEditingController.clear(),
child: const Text('Submit'),
),
],
),
);
}
}

build 메소드에서 useTextEditingController를 호출해 textEditingController를 생성하고 이를 TextField에 전달하고, ElevatedButton의 활성 상태를 변경하는 로직으로 사용합니다. 하지만 여전히 우리가 원하는 TextField의 입력 상태에 따라 ElevatedButton의 상태가 변경되지 않습니다. 상태를 변경하려면 rebuild해야 하는데, setState 역시 사용할 수 없습니다. 이쯤되면 flutter_hooks 학습을 멈추고 프로젝트 의존성에서 flutter_hooks를 제거합니다.

상태를 담은 ValueNotifier를 생성하는 useState

useState 함수는 상태를 ValueNotifier에 담아 반환합니다. ValueNotifier는 값의 변경을 감지할 수 있는 플러터 기본 객체 중 하나 입니다.

useState 함수의 구현체를 보면 아래와 같이 ValueNotifier에 addListener를 호출해 값 변경을 모니터링 합니다. 값이 변경되면 호출되는 _listener에서는 setState를 호출해 리빌드를 시킵니다.

// flutter_hooks
ValueNotifier<T> useState<T>(T initialData) {
return use(_StateHook(initialData: initialData));
}

class _StateHook<T> extends Hook<ValueNotifier<T>> {
const _StateHook({required this.initialData});

final T initialData;

@override
_StateHookState<T> createState() => _StateHookState();
}

class _StateHookState<T> extends HookState<ValueNotifier<T>, _StateHook<T>> {
late final _state = ValueNotifier<T>(hook.initialData)
..addListener(_listener);

@override
void dispose() {
_state.dispose();
}

@override
ValueNotifier<T> build(BuildContext context) => _state;

void _listener() {
setState(() {});
}

@override
Object? get debugValue => _state.value;

@override
String get debugLabel => 'useState<$T>';
}

useState로 isEmpty라는 값을 관리하도록 변경해 봅시다.

class ChatInputHooks extends HookWidget {
const ChatInputHooks({super.key});

@override
Widget build(BuildContext context) {
final textEditingController = useTextEditingController();
final isEmpty = useState(textEditingController.text.isEmpty);
return Container(
color: Colors.grey[300],
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: textEditingController,
decoration: const InputDecoration(hintText: 'Enter your message'),
),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed:
isEmpty.value ? null : () => textEditingController.clear(),
child: const Text('Submit'),
),
],
),
);
}
}

useState 함수의 인자로 textEditingController의 text.isEmpty를 호출해 초기값을 전달했습니다. isEmpty는 ValueNotifier 이므로 ElevatedButon의 onPressed에 콜백을 전달하는 삼항 연산자에 isEmpty.value를 사용해 분기 처리했습니다. 이제 ValueNotifier 타입인 isEmpty의 value 값을 변경해봅시다. initState를 쓸 수 없으니 build 메소드에 listener 함수를 선언하고, textEditingController의 addListener에 전달합니다.

class ChatInputHooks extends HookWidget {
const ChatInputHooks({super.key});

@override
Widget build(BuildContext context) {
final textEditingController = useTextEditingController();
final isEmpty = useState(textEditingController.text.isEmpty);
listener() {
return isEmpty.value = textEditingController.text.isEmpty;
}
textEditingController.addListener(listener);
return Container(
color: Colors.grey[300],
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: textEditingController,
decoration: const InputDecoration(hintText: 'Enter your message'),
),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed:
isEmpty.value ? null : () => textEditingController.clear(),
child: const Text('Submit'),
),
],
),
);
}
}

잘 동작하지만 dispose를 호출할 방법이 없습니다. dispose를 호출하도록 도와주는 flutter_hooks 함수가 useEffect입니다.

build 시 호출되고, dispose 시 호출될 함수를 반환하는 함수를 useEffect에 전달하기

끝으로 flutter_hooks에서 가장 이해하기 어렵지만 가장 유용한 useEffect를 적용해 봅시다.

useEffect 함수의 첫번째 인자는 Dispose를 옵셔널하게 반환하는 Function 입니다. Disposes는 Function의 typedef이기 때문에 effect 인자는 Function을 반환하는 Function입니다.

typedef Dispose = void Function();

void useEffect(Dispose? Function() effect, [List<Object?>? keys]) {
use(_EffectHook(effect, keys));
}

effect가 반환하는 함수가 바로 useEffect를 호출한 HookWidget이 dispose될 때 호출될 함수입니다. 따라서 TextEditingController의 dispose 처리를 하는 함수를 useEffect의 effect 인자로 전달한 함수에서 반환하도록 합니다. 또한 useEffect의 두번째 인자인 배열에는 Object 타입의 배열을 옵셔널하게 전달할 수 있는데, keys의 값로 전달한 값이 변경될 때 마다 호출해 사이드 이펙트를 격리하게 도와줍니다. 만약 keys에 값을 사이드 이펙트를 일으키는 값을 전달하지 않으면 useEffect에 전달한 함수는 build와 함께 호출됩니다. 우리는 사이드 이펙트를 TextEditingController의 addListener를 통해 처리하기 때문에 keys에는 아무런 값도 전달하지 않아도 됩니다. 다만 사이드 이펙트를 격리하는 측면에서 listener 함수를 useEffect의 effect 함수 내부에 선언하고, addListener도 effect 함수 내부에서 호출하도록 변경합니다.

class ChatInputHooks extends HookWidget {
const ChatInputHooks({super.key});

@override
Widget build(BuildContext context) {
final textEditingController = useTextEditingController();
final isEmpty = useState(textEditingController.text.isEmpty);
useEffect(() {
listener() {
return isEmpty.value = textEditingController.text.isEmpty;
}

textEditingController.addListener(listener);
return () {
textEditingController.removeListener(listener);
textEditingController.dispose();
};
}, []);
return Container(
color: Colors.grey[300],
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: textEditingController,
decoration: const InputDecoration(hintText: 'Enter your message'),
),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed:
isEmpty.value ? null : () => textEditingController.clear(),
child: const Text('Submit'),
),
],
),
);
}
}

StatefulWidget과 Mixin으로 구현할 땐 20줄의 코드가 필요했지만, HookWidget은 12줄로 처리됐습니다. 여기에서 멈추면 flutter_hooks의 장점이 크게 와닿지 않습니다. 그저 20줄의 코드를 12줄로 줄여주지만 러닝커브가 존재하는 패키지 정도로 flutter_hooks를 인식하게 됩니다. 로직의 재사용 문제까지 간단히 해결해봅시다.

useState와 useEffect를 재사용 가능한 함수로 분리하기

TextEditingController를 전달받아 bool 타입의 상태를 반환하는 useIsTextEditingControllerEmpty 함수를 선언했습니다. HookWidget의 build 함수에 있던 useState와 useEffect 로직을 useIsTextEditingControllerEmpty 함수로 분리했습니다. useIsTextEditingControllerEmpty 함수만 호출해주면 TextEditingController의 입력 상태에 따라 변경된 isEmpty값이 반환되며 위젯을 리빌드합니다.

bool useIsTextEditingControllerEmpty(TextEditingController textEditController) {
final isEmpty = useState(textEditController.text.isEmpty);
useEffect(() {
listener() => isEmpty.value = textEditController.text.isEmpty;
textEditController.addListener(listener);
return () {
textEditController.removeListener(listener);
textEditController.dispose();
};
}, []);
return isEmpty.value;
}

class ChatInputHooks extends HookWidget {
const ChatInputHooks({super.key});

@override
Widget build(BuildContext context) {
final textEditingController = useTextEditingController();
final isEmpty = useIsTextEditingControllerEmpty(textEditingController);
return Container(
color: Colors.grey[300],
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: textEditingController,
decoration: const InputDecoration(hintText: 'Enter your message'),
),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: isEmpty ? null : () => textEditingController.clear(),
child: const Text('Submit'),
),
],
),
);
}
}

리액트훅을 플러터 기반으로 구현했습니다. 훅은 위젯의 라이프 사이클을 관리하는 새로운 종류의 객체입니다. 중복 코드를 분리해 코드 재사용이 가능하도록 만드는 단 하나의 이유로 존재합니다.

다시 flutter_hooks의 설명을 살펴봅시다. 중복 코드를 분리해 코드 재사용이 가능하도록 만드는 단 하나의 이유를 충족하는 코드가 만들어졌습니다.

끝으로

flutter_hooks를 이용하면 UI 관련 로직을 재사용 가능하게 만들어줍니다. 재사용 가능하도록 분리된 UI 로직은 관리하지 않으면 비즈니스 로직과 복잡한 상태가 포함되는 경우가 많습니다. flutter_hooks는 상태관리 패키지가 아닙니다. flutter_hooks는 UI 로직의 재사용 측면에서는 활용해야 합니다. 비즈니스 로직과 데이터는 riverpod, bloc 상태관리 패키지나 아키텍쳐를 바탕으로 관리해야 합니다. 기회가 된다면 아키텍쳐를 소개하는 포스팅으로 찾아오겠습니다.

그럼 언제나 그렇듯 Happy Coding👨‍💻

--

--

Cody Yun
Flutter Seoul

I wanna be a full stack software engineer at the side of user-facing application.