setState부터 Provider·Riverpod까지 상태 관리
setState부터 Provider·Riverpod까지 상태 관리
요약: 값이 누구의 기억인지 먼저 나누고, 로컬은 setState, 공유는 Provider로 올린다.
1. 왜 / 언제
상태 관리 패키지를 고르기 전에 질문 하나다. 이 값은 누구의 기억인가.
입력 중인 글자, 토글, 버튼 로딩은 그 위젯 근처면 된다. setState가 가장 싸다. 로그인 세션, 장바구니, 테마 설정은 화면이 바뀌어도 남아야 한다. 그때 공유 계층이 필요하다.
모든 키 입력을 전역 스토어에 넣으면 리빌드가 늘고 코드가 길어진다. 반대로 세션을 화면 State에 두면 라우트가 바뀌는 순간 로그아웃과 같아진다.
패키지는 그 다음이다. 팀을 하나로 고정한다. 한 앱에 Provider·Riverpod·Bloc을 섞지 않는다.
2. 핵심
Flutter에서 화면이 바뀌는 기본 장치는 setState다. 값이 바뀌었다고 표시하면 그 State의 build가 다시 돈다. 위젯 트리는 위에서 아래로 다시 설명된다. const 자식과 분리한 위젯은 입력이 같으면 넘어갈 수 있다.
위젯은 자주 다시 만들어진다. 공유 값을 위젯 필드에 두면 사라진다. 트리 위쪽에 값을 두고 아래가 읽게 하는 장치가 InheritedWidget이다. Provider는 그 패턴을 쓰기 쉽게 포장한 것이다.
| 상태 | 위치 | 이유 |
|---|---|---|
| 폼 입력, 토글, 로컬 로딩 | 위젯 setState |
화면과 수명이 같다 |
| 로그인 세션 | 앱 스코프 | 라우트가 바뀌어도 유지 |
| 장바구니·설정 | 공유 레이어 | 여러 화면이 읽는다 |
| 서버 목록 캐시 | 저장소/쿼리 계층 | UI 상태가 아니다 |
읽기 범위가 곧 리빌드 범위다. watch는 값이 바뀔 때 그 위젯을 다시 그린다. 큰 화면 루트에서 세션을 watch하면 타이핑마다 전체가 흔들릴 수 있다. 필요한 잎만 구독한다.
| 패키지 | 언제 |
|---|---|
| Provider | 개념이 단순하고 생태계가 클 때 |
| Riverpod | 컴파일 안전·테스트·스코프가 필요할 때 |
| Bloc | 이벤트/상태 파이프라인을 팀이 원할 때 |
하나를 팀 표준으로 둔다.
3. 예제
로컬 setState
class LikeButton extends StatefulWidget {
const LikeButton({super.key});
@override
State<LikeButton> createState() => _LikeButtonState();
}
class _LikeButtonState extends State<LikeButton> {
bool liked = false;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () => setState(() => liked = !liked),
icon: Icon(liked ? Icons.favorite : Icons.favorite_border),
);
}
}
좋아요 토글은 이 버튼의 기억이다. 앱 스토어에 올리지 않는다.
Provider로 세션 공유
dependencies:
provider: ^6.1.0
class Auth extends ChangeNotifier {
Auth();
String? userId;
bool get signedIn => userId != null;
void signIn(String id) {
userId = id;
notifyListeners();
}
void signOut() {
userId = null;
notifyListeners();
}
}
루트에 올린다.
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<Auth>(
create: (_) => Auth(),
child: const MaterialApp(
home: HomePage(),
),
);
}
}
읽는 쪽은 범위를 나눈다.
class HomeHeader extends StatelessWidget {
const HomeHeader({super.key});
@override
Widget build(BuildContext context) {
final bool signedIn = context.watch<Auth>().signedIn;
return Text(signedIn ? '로그인됨' : '게스트');
}
}
class SignInButton extends StatelessWidget {
const SignInButton({super.key});
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: () => context.read<Auth>().signIn('user-1'),
child: const Text('로그인'),
);
}
}
watch는 구독이다. read는 한 번 읽고 액션만 호출한다. 버튼은 read가 맞다. 헤더처럼 값에 따라 그려져야 하는 곳만 watch한다.
select로 필드 하나만 구독하면 리빌드가 더 줄어든다.
final String? userId = context.select<Auth, String?>((Auth a) => a.userId);
위젯 분리
큰 build 하나에 watch를 여러 개 두지 않는다. 구독하는 위젯을 작게 쪼갠다. 카운터·헤더·리스트를 각각 Stateless로 빼면 상위 setState가 전체를 흔들지 않는다.
4. 흔한 실수
| 실수 | 결과 | 대안 |
|---|---|---|
| 모든 입력을 전역에 둔다 | 리빌드·복잡도 증가 | 로컬은 setState |
화면 루트에서 watch |
타이핑마다 전체 재빌드 | 잎 위젯만 구독 |
watch로 액션 호출 |
빌드 중 부수 효과 | 콜백에서 read |
Provider를 build마다 생성 |
상태가 리셋된다 | 루트 create 한 번 |
| 패키지를 화면마다 다르게 | 팀 온보딩 실패 | 표준 하나 |
notifyListeners는 값이 실제로 바뀔 때만 호출한다. 같은 값으로 반복 알리면 구독 위젯이 불필요하게 다시 돈다.
비동기 뒤에 notifyListeners를 호출할 때도 객체가 아직 쓰이는지 본다. ChangeNotifier를 dispose한 뒤 알리면 예외가 난다.
전역이 필요해 보여도 먼저 위젯을 한 단계 올린다. 부모 State로 충분하면 Provider를 열지 않는다.
정리
상태 관리 프레임워크보다 “이 값이 누구의 기억인가” 를 먼저 정한다. 로컬은 setState, 공유는 Provider(또는 팀 표준)다. 구독 범위가 곧 리빌드 범위다.
연습
- 토글 버튼을
setState만으로 만든다. Provider에 올리지 않는다. Auth를ChangeNotifier로 두고 두 화면이 로그인 여부를 공유하게 한다.- 헤더는
watch, 버튼은read로 나눈다. - 루트
watch를 잎 위젯으로 옮겨 리빌드 범위를 줄인다.
댓글
0아직 댓글이 없습니다. 첫 댓글을 남겨보세요.