[Flutter] StatefulWidget을 대체하는 Flutter Hooks

Cody Yun
20 min readNov 21, 2023

--

Flutter Hooks 활용을 검토하는 과정을 기록하기 위해 포스팅을 작성합니다.

불변성

플러터를 학습 하면서 자주 마주하는 단어가 있습니다. Immutability는 불변성이라는 뜻으로 한글 사전을 찾아보면 “변하지 않는 성질”이라 설명하고 있습니다.

immutability, 불변성
다음 영어 사전 : https://dic.daum.net/word/view.do?wordid=ekw000084380
다음 한국어 사전 : https://dic.daum.net/word/view.do?wordid=kkw000119462

하드웨어의 발달, 사용자 눈높이의 변화, 서비스 간 치열한 경쟁 등으로 소프트웨어 요구사항이 복잡해지고 있습니다. 복잡한 요구사항은 복잡한 상태를 의미합니다. 그런데 상태를 변경할 수 없다뇨? 저뿐만 아니라 플러터를 학습하는 많은 개발자들이 혼란스러워합니다. 왜냐하면 상태를 변경할 수 없는 소프트웨어는 무가치하기 때문입니다. 계산기를 예로 들어볼까요? 초기 상태를 변경할 수 없기 때문에 계산을 위해 숫자를 입력할 수 없습니다. 설령 어찌저찌 입력했다하더라도 잘못 입력한 경우 수정할 수 없습니다. 친구에게 카톡으로 보낼 회사 욕을 회사 단톡방에 보내도 삭제할 수도 없습니다. 바로 불변성 때문에요.

불변성을 대하는 플러터의 자세

그러면 플러터에서 말하는 불변성이 어떤 건지 알아봅시다. StatelessWidget과 Column을 이용해 상단에는 TextFormField를 배치하고, 하단에는 Text 위젯을 배치해봅시다. 하단에 배치되는 Text 위젯에는 외부에서 문자열을 옵셔널하게 전달하고, 만약 전달된 값이 없다면 “표시할 텍스트가 없습니다.”라는 문자를 출력하도록 합시다.

import 'package:flutter/material.dart';

void main() {
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
const TextInputWidget(),
TextInputResultWidget(),
],
),
),
),
);
}
}
class TextInputWidget extends StatelessWidget {
const TextInputWidget({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: TextFormField(),
);
}
}
class TextInputResultWidget extends StatelessWidget {
String? inputText;
TextInputResultWidget({
super.key,
this.inputText,
});
@override
Widget build(BuildContext context) {
return Center(
child: Text(inputText ?? "표시할 텍스트가 없습니다."),
);
}
}

현재는 TextFormField로 사용자 입력을 받을 수는 있지만 입력한 텍스트를 TextInputResultWidget으로 전달해 출력해주진 않고 있습니다. TextFormField에 입력 이벤트를 처리하거나, 입력된 값을 가져오기 위해 TextEditingController를 사용할 수 있습니다. TextInputWidget의 TextFormField의 입력값을 TextInputResultWidget의 Text에 적용하기 위해 다양한 방식을 시도할 수 있지만, TextInputWidget과 TextInputResultWidget의 공통 부모 위젯인 App을 StatefulWidget으로 변경하고, App의 State에 TextEditingController를 추가하겠습니다.

AppState의 TextEditingController를 TextInputWidget 생성자를 전달하고, TextFormField의 controller에 지정합니다. AppState의 initState에서는 TextEditingController에 addListener를 통해 입력 상태가 변경됐을 때 setState를 호출해 AppState가 rebuild 되도록 합니다. dispose에서는 addListener를 통해 등록된 리스너를 해제하도록 removeListener를 호출합니다. TextInputResultWidget에는 이제 생성자로 TextEditController의 text를 전달합니다.

import 'package:flutter/material.dart';

void main() {
runApp(const App());
}
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
TextEditingController textEditingController = TextEditingController();
@override
void initState() {
super.initState();
textEditingController.addListener(_onChangedTextEdit);
}
@override
void dispose() {
textEditingController.removeListener(_onChangedTextEdit);
super.dispose();
}
void _onChangedTextEdit() {
setState(() {});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
TextInputWidget(
textEditingController: textEditingController,
),
TextInputResultWidget(
inputText: textEditingController.text,
),
],
),
),
),
);
}
}
class TextInputWidget extends StatelessWidget {
final TextEditingController textEditingController;
const TextInputWidget({
super.key,
required this.textEditingController,
});
@override
Widget build(BuildContext context) {
return Center(
child: TextFormField(
controller: textEditingController,
),
);
}
}
class TextInputResultWidget extends StatelessWidget {
final String inputText;
const TextInputResultWidget({
super.key,
required this.inputText,
});
@override
Widget build(BuildContext context) {
return Center(
child: Text(inputText.isEmpty ? "표시할 텍스트가 없습니다." : inputText),
);
}
}

TextFormField에 사용자가 텍스트를 입력하면 리스너에 의해 setState가 호출되며 AppState가 리빌드됩니다. 리빌드될 때 TextEditingController의 text가 TextInputResultWidget의 생성자로 전달되며 화면이 갱신됩니다. 이렇듯 플러터는 상태를 변경하지 않고 변경된 상태를 기반으로 위젯을 다시 생성함으로써 변하는 상태로 부터 어플리케이션의 복잡도를 제어합니다. 그런데 플러터에서는 위젯을 다시 생성하기 위해서는 StatelessWidget이 아닌 StatefulWidget을 확장해야 합니다. StatelessWidget을 StatefulWidget으로 바꾸는건 귀찮은 일입니다. 또한 상태 변경에 따라 화면을 다시 그리기 위해 setState를 호출해줘야 하는데 자주 놓치곤 합니다. 이런 문제를 해결할 수 있는 방법이 없을까요?

Flutter Hooks

리액트를 공부하면서 클래스 기반의 컴포넌트보다 함수형 컴포넌트를 많이 사용한다는 이야기를 들었습니다. 클래스 기반의 컴포넌트가 더 좋아보여 의문을 품고 있던 중 리액트 훅을 공부하고 나니 함수형 컴포넌트를 많이 사용하는 이유를 알 것 같았습니다.

Flutter Hooks🔗(이하 플러터훅)는 플러터 상태관리 라이브러리인 리버팟을 만든 Remi Rousselet🔗이 만든 라이브러리입니다. 플러터훅의 pub.dev에는 플러터훅을 위젯의 라이프 사이클을 관리하는 새로운 종류의 객체이며 위젯 간 코드 재사용성을 높여 코드 중복을 줄이는게 목적이라 설명하고 있습니다.

플러터훅의 pub.dev에는 AnimationController 관련된 예제를 제공하고 있지만 실제 애니메이션을 처리하는 예제는 아니라 플러터훅의 장점을 그저 보일러플레이트 코드를 줄여 준다고 생각했습니다.

class Example extends StatefulWidget {
final Duration duration;
const Example({Key? key, required this.duration})
: super(key: key);
@override
_ExampleState createState() => _ExampleState();
}

// 일반적인 AnimationController 사용 방식 적용
class _ExampleState extends State<Example> with SingleTickerProviderStateMixin {
AnimationController? _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: widget.duration);
}
@override
void didUpdateWidget(Example oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.duration != oldWidget.duration) {
_controller!.duration = widget.duration;
}
}
@override
void dispose() {
_controller!.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container();
}
}

class Example extends HookWidget {
const Example({Key? key, required this.duration})
: super(key: key);
final Duration duration;
@override
Widget build(BuildContext context) {
// useAnimationController로 AnimationController 객체 생성
final controller = useAnimationController(duration: duration);
return Container();
}
}

플러터훅을 사용 방법은 단순합니다. 1) StatelessWidget 대신 HookWidget을 확장하도록 변경하고 2) 플러터훅 객체를 build 메소드에서 생성합니다. 사용 방식은 리액트훅과 거의 동일하기 때문에 리액트 개발자가 플러터훅을 접한다면 손쉽게 사용할 수 있지만, 플러터 개발자에겐 기존 상태관리 방식과 달라 낯선게 사실입니다.

flutter_hook 을 활용한 상태관리

앞서 TextFormField의 입력 값을 다른 위젯 트리에 존재하는 위젯에 전달하기위해 공통의 부모 위젯을 StatefulWidget으로 바꾸었습니다. StatefulWidget의 라이프사이클에 따라 객체를 생성하고, 컨트롤러의 이벤트 등록과 해제하는 코드도 추가했습니다. 아래 StatefulWidget을 플러터훅으로 변경해봅시다.

👇HookWidget과 useTextEditingController, useListenable을 활용한 방식

import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter/material.dart';

void main() {
runApp(const App());
}

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

@override
Widget build(BuildContext context) {
final textEditController = useTextEditingController();
useListenable(textEditController);
return MaterialApp(
home: Scaffold(
body: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
TextInputWidget(
textEditingController: textEditController,
),
TextInputResultWidget(
inputText: textEditController.text,
),
],
),
),
),
);
}
}

class TextInputWidget extends StatelessWidget {
final TextEditingController textEditingController;
const TextInputWidget({
super.key,
required this.textEditingController,
});

@override
Widget build(BuildContext context) {
return Center(
child: TextFormField(
controller: textEditingController,
),
);
}
}

class TextInputResultWidget extends StatelessWidget {
final String inputText;
const TextInputResultWidget({
super.key,
required this.inputText,
});

@override
Widget build(BuildContext context) {
return Center(
child: Text(inputText.isEmpty ? "표시할 텍스트가 없습니다." : inputText),
);
}
}

StatefulWidget을 HookWidget으로 바꿈으로써 StatefulWidget과 State로 나누어진 보일러플레이트 코드와 initState, dispose 라이프사이클 메소드가 필요 없어졌습니다.

바로 이러한 점이 플러터훅을 새로운 종류의 라이프사이클 객체로 설명하는 이유입니다. 사라진 State와 initState, dispose 대신 build 메소드 내부에는 1) useTextEditingController 메소드를 호출해 textEditController 객체를 생성했고, 2) textEditController를 useListenable 메소드를 호출하며 인자로 전달해 textEditController의 상태가 변경되면 리빌드되도록 해줬습니다.

flutter_hook 으로 StatefulWidget 대체하기

아래는 플러터 프로젝트 생성 시 함께 만들어진 카운터앱입니다. StatefulWidget을 확장한 MyHomePage 위젯이 있습니다. 버튼을 클릭하면 _counter라는 상태를 ++ 연산하고, 리빌드를 위해 setState를 호출하는 간단한 예제입니다.

class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}

플러터의 기본 카운터앱도 플러터훅을 사용해 상태관리를 하도록 바꿔봅시다.

class MyHomePage extends HookWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
final counter = useState(0);
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'${counter.value}',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => counter.value++,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}

앞서 useTextEditController를 사용했던것과 마찬가지로 1) StatefulWidget을 HookWidget으로 바꿉니다. _counter가 선언되어 있던 State 객체는 더 이상 필요없고, 2) build 메소드 내부에서 useState 메소드를 호출해 상태 객체를 생성합니다. useState에는 생성할 상태의 초기값을 전달합니다. 3) useState를 통해 생성된 상태에서 값을 꺼내오거나 값을 변경할 때 counter.value를 사용합니다. useState를 통해 생성된 상태의 value를 변경하면 HookWidget은 자동으로 리빌드됩니다.

끝으로

useTextEditController, useListenable, useState를 활용해 새로운 방식의 상태제어 방식을 다뤘습니다. StatefulWidget 대신 플러터훅을 사용했을 때 가독성이 올라갔을까요? 새로운 라이프사이클 객체가 보일러플레이트 코드를 줄여준건 확실합니다. 주변에 flutter_hooks 사용 여부를 물어보면 아직까지는 많이 사용하지 않는것 같습니다. 하지만 리액트훅이 리액트의 함수형 컴포넌트를 주류로 만들었듯이 플러터훅도 선언형 UI와 잘 어울리는 함수형 패러다임을 주류로 만들지 않을까 하는 기대를 해보며 글을 마칩니다.

--

--

Cody Yun

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