`StateNotifier`에서 마이그레이션
Riverpod 2.0과 함께 새로운 클래스인
Notifier / AsyncNotifier가 도입되었습니다.
이제 StateNotifier보다는 이 새로운 API를 사용하는 것이 권장됩니다.
이 페이지에서는 더 이상 사용되지 않는 StateNotifier에서 새 API로 마이그레이션하는 방법을 설명합니다.
AsyncNotifier가 가져온 가장 큰 이점은 향상된 async 지원입니다.
AsyncNotifier는 UI에서 상태를 변경할 수 있는 방법을 노출하는 FutureProvider라고 생각할 수 있습니다.
또한 새로운 (Async)Notifier는 다음과 같은 특징이 있습니다:
- 클래스 안에서
Ref객체를 노출합니다 - 코드 생성을 사용할 때와 사용하지 않을 때의 문법이 비슷합니다
- 동기 버전과 비동기 버전의 문법이 비슷합니다
- 로직을 Provider에서 떼어 내 Notifier 자체에 모읍니다
Notifier를 정의하는 방법과 StateNotifier와의 차이, 그리고 비동기 상태를 위해
새로운 AsyncNotifier로 마이그레이션하는 방법을 살펴보겠습니다.
새 문법 비교
비교에 앞서 Notifier를 정의하는 방법을 알아 두세요.
자세한 내용은 Provider를 참고하세요.
먼저 기존 StateNotifier 문법으로 예제를 작성해 보겠습니다:
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);
void increment() => state++;
void decrement() => state--;
}
final counterNotifierProvider =
StateNotifierProvider<CounterNotifier, int>((ref) {
return CounterNotifier();
});
같은 예제를 새로운 Notifier API로 작성하면 대략 다음과 같습니다:
- riverpod
- riverpod_generator
class CounterNotifier extends Notifier<int> {
int build() => 0;
void increment() => state++;
void decrement() => state++;
}
final counterNotifierProvider = NotifierProvider<CounterNotifier, int>(CounterNotifier.new);
class CounterNotifier extends _$CounterNotifier {
int build() => 0;
void increment() => state++;
void decrement() => state--;
}
Notifier와 StateNotifier를 비교하면 다음과 같은 주요 차이점을 볼 수 있습니다:
StateNotifier는 반응형 의존성을 provider에서 선언하지만,Notifier는 이 로직을build메서드에 모읍니다StateNotifier는 초기화 과정 전체가 provider와 생성자에 나뉘어 있지만,Notifier는 이런 로직을 한곳에 둡니다StateNotifier와 달리Notifier의 생성자에는 어떤 로직도 작성하지 않는다는 점에 주목하세요
Notifier의 비동기 버전인 AsyncNotifier에서도 같은 결론을 얻을 수 있습니다.
비동기 StateNotifier 마이그레이션하기
새 API 문법의 가장 큰 매력은 비동기 데이터를 다룰 때 개발 경험(DX)이 좋아졌다는 점입니다.
다음 예제를 보겠습니다:
class AsyncTodosNotifier extends StateNotifier<AsyncValue<List<Todo>>> {
AsyncTodosNotifier() : super(const AsyncLoading()) {
_postInit();
}
Future<void> _postInit() async {
state = await AsyncValue.guard(() async {
final json = await http.get('api/todos');
return [...json.map(Todo.fromJson)];
});
}
// ...
}
위 예제를 새로운 AsyncNotifier API로 다시 작성하면 다음과 같습니다:
- riverpod
- riverpod_generator
class AsyncTodosNotifier extends AsyncNotifier<List<Todo>> {
FutureOr<List<Todo>> build() async {
final json = await http.get('api/todos');
return [...json.map(Todo.fromJson)];
}
// ...
}
final asyncTodosNotifier =
AsyncNotifierProvider<AsyncTodosNotifier, List<Todo>>(
AsyncTodosNotifier.new,
);
class AsyncTodosNotifier extends _$AsyncTodosNotifier {
FutureOr<List<Todo>> build() async {
final json = await http.get('api/todos');
return [...json.map(Todo.fromJson)];
}
// ...
}
AsyncNotifier도 Notifier처럼 더 단순하고 일관된 API를 제공합니다.
여기서는 AsyncNotifier를 메서드가 있는 FutureProvider로 쉽게 이해할 수 있습니다.
AsyncNotifier에는 StateNotifier에 없는 여러 유틸리티와 getter가 있습니다. 예를 들어
future와
update가 있습니다.
덕분에 비동기 mutation과 부수 효과를 처리하는 로직을 훨씬 간단하게 작성할 수 있습니다.
자세한 내용은 Provider를 참고하세요.
다음은 StateNotifier<AsyncValue<T>>에서 AsyncNotifier<T>로 마이그레이션하는 과정을 요약한 것입니다:
- 초기화 로직을
build에 넣습니다 - 초기화나 부수 효과 메서드에 있는
catch/try블록을 모두 제거합니다 build에서AsyncValue.guard를 모두 제거합니다.build가 반환한Future는 자동으로AsyncValue로 변환되기 때문입니다
장점
몇 가지 예제를 살펴봤으니, 이제 Notifier와 AsyncNotifier의 주요 장점을 정리해 보겠습니다:
- 새 문법은 특히 비동기 상태에서 훨씬 단순하고 읽기 쉽습니다
- 새 API는 대체로 보일러플레이트 코드가 적습니다
- 어떤 종류의 provider를 작성하든 문법이 통일되어, 코드 생성이 가능해집니다 (코드 생성에 대하여 참고)
더 나아가 다른 차이점과 공통점도 살펴보겠습니다.
명시적인 .family와 .autoDispose 수식어
또 하나의 중요한 차이는 새 API에서 family와 자동 폐기를 다루는 방식입니다.
수식어는 클래스 안에서 명시적으로 지정되며, 모든 매개변수는 build 메서드에 직접 주입되므로
초기화 로직에서 바로 사용할 수 있습니다.
덕분에 가독성이 좋아지고, 코드가 간결해지며, 실수도 줄어듭니다.
StateNotifierProvider.family를 정의하는 다음 예제를 보겠습니다.
class BugsEncounteredNotifier extends StateNotifier<AsyncValue<int>> {
BugsEncounteredNotifier({
required this.ref,
required this.featureId,
}) : super(const AsyncData(99));
final String featureId;
final Ref ref;
Future<void> fix(int amount) async {
state = await AsyncValue.guard(() async {
final old = state.requireValue;
final result =
await ref.read(taskTrackerProvider).fix(id: featureId, fixed: amount);
return max(old - result, 0);
});
}
}
final bugsEncounteredNotifierProvider = StateNotifierProvider.family
.autoDispose<BugsEncounteredNotifier, AsyncValue<int>, String>((ref, id) {
return BugsEncounteredNotifier(ref: ref, featureId: id);
});
BugsEncounteredNotifier는... 무겁고 읽기 어렵게 느껴집니다.
마이그레이션된 AsyncNotifier 버전을 살펴보겠습니다:
- riverpod
- riverpod_generator
class BugsEncounteredNotifier extends AsyncNotifier<int> {
BugsEncounteredNotifier(this.arg);
final String arg;
FutureOr<int> build() {
return 99;
}
Future<void> fix(int amount) async {
final old = await future;
final result =
await ref.read(taskTrackerProvider).fix(id: this.arg, fixed: amount);
state = AsyncData(max(old - result, 0));
}
}
final bugsEncounteredNotifierProvider = AsyncNotifierProvider.family
.autoDispose<BugsEncounteredNotifier, int, String>(
BugsEncounteredNotifier.new,
);
class BugsEncounteredNotifier extends _$BugsEncounteredNotifier {
FutureOr<int> build(String featureId) {
return 99;
}
Future<void> fix(int amount) async {
final old = await future;
final result = await ref
.read(taskTrackerProvider)
.fix(id: this.featureId, fixed: amount);
state = AsyncData(max(old - result, 0));
}
}
마이그레이션된 버전은 훨씬 가볍게 읽힐 것입니다.
(Async)Notifier의 .family 매개변수는 this.arg(코드 생성을 사용할 때는 this.paramName)로 접근할 수 있습니다
생명주기 동작이 다릅니다
Notifier/AsyncNotifier와 StateNotifier는 생명주기가 크게 다릅니다.
이 예제는 기존 API의 로직이 얼마나 흩어져 있는지 다시 한번 보여 줍니다:
class MyNotifier extends StateNotifier<int> {
MyNotifier(this.ref, this.period) : super(0) {
// 1 init logic
_timer = Timer.periodic(period, (t) => update()); // 2 side effect on init
}
final Duration period;
final Ref ref;
late final Timer _timer;
Future<void> update() async {
await ref.read(repositoryProvider).update(state + 1); // 3 mutation
if (mounted) state++; // 4 check for mounted props
}
void dispose() {
_timer.cancel(); // 5 custom dispose logic
super.dispose();
}
}
final myNotifierProvider = StateNotifierProvider<MyNotifier, int>((ref) {
// 6 provider definition
final period = ref.watch(durationProvider); // 7 reactive dependency logic
return MyNotifier(ref, period); // 8 pipe down `ref`
});
여기서 durationProvider가 갱신되면 MyNotifier는 폐기되고, 인스턴스가 새로 생성되며
내부 상태도 다시 초기화됩니다.
게다가 다른 모든 provider와 달리 dispose 콜백을 클래스 안에
따로 정의해야 합니다.
마지막으로 provider 안에 ref.onDispose를 작성하는 것도 여전히 가능하므로, 이 API에서는 로직이
얼마나 흩어질 수 있는지 다시 한번 드러납니다. 개발자는 이 Notifier의 동작을 이해하기 위해
무려 여덟(8!) 군데를 살펴봐야 할 수도 있습니다!
이런 모호함은 Riverpod 2.0에서 해결되었습니다.
기존 dispose vs ref.onDispose
StateNotifier의 dispose 메서드는 notifier 자체의 폐기 이벤트를 가리킵니다. 즉,
자기 자신이 폐기되기 전에 호출되는 콜백입니다.
(Async)Notifier에는 이런 속성이 없습니다. 다시 빌드될 때 notifier 자체는 폐기되지 않고,
내부 상태만 폐기되기 때문입니다.
새 notifier에서는 다른 모든 provider와 마찬가지로 ref.onDispose(및 기타 메서드)를 통해
한곳에서만 폐기 생명주기를 처리합니다.
이렇게 API가 단순해지고 개발 경험도 좋아져, 생명주기와 관련된 부수 효과를 이해하려면
build 메서드 한곳만 보면 됩니다.
요약하면, 내부 상태가 다시 빌드되기 전에 실행될 콜백을 등록하려면
다른 모든 provider처럼 ref.onDispose를 사용하면 됩니다.
위 코드는 다음과 같이 마이그레이션할 수 있습니다:
- riverpod
- riverpod_generator
class MyNotifier extends Notifier<int> {
int build() {
// 코드를 여기, 한곳에서만 읽고 쓰면 됩니다
final period = ref.watch(durationProvider);
final timer = Timer.periodic(period, (t) => update());
ref.onDispose(timer.cancel);
return 0;
}
Future<void> update() async {
await ref.read(repositoryProvider).update(state + 1);
// `mounted`는 더 이상 없습니다!
state++; // 예외가 발생할 수 있습니다.
}
}
final myNotifierProvider = NotifierProvider<MyNotifier, int>(MyNotifier.new);
class MyNotifier extends _$MyNotifier {
int build() {
// 코드를 여기, 한곳에서만 읽고 쓰면 됩니다
final period = ref.watch(durationProvider);
final timer = Timer.periodic(period, (t) => update());
ref.onDispose(timer.cancel);
return 0;
}
Future<void> update() async {
await ref.read(repositoryProvider).update(state + 1);
// `mounted`는 더 이상 없습니다!
state++; // 예외가 발생할 수 있습니다.
}
}
이 코드는 확실히 단순해졌지만, 아직 해결되지 않은 문제가 있습니다.
update를 실행하는 동안 notifier가 아직 살아 있는지 알 수 없다는 점입니다.
이 때문에 원치 않는 StateError가 발생할 수 있습니다.
더 이상 mounted는 없습니다
이 문제는 (Async)Notifier에 StateNotifier에서 쓸 수 있던 mounted 속성이
없기 때문에 발생합니다.
생명주기의 차이를 생각하면 당연한 결과입니다. 만들 수는 있겠지만, 새 notifier에서 mounted
속성은 오해를 부를 수 있습니다. mounted가 거의 항상 true일 것이기 때문입니다.
직접 우회 방법을 만들 수도 있지만, 비동기 작업을 취소하는 방식으로 해결하는 것을 권장합니다.
작업 취소는 직접 만든 Completer나 그와 비슷한 방식으로 구현할 수 있습니다.
예를 들어 네트워크 요청에 Dio를 사용한다면 cancel token을
사용해 보세요(자동 폐기도 참고하세요).
따라서 위 예제는 다음과 같이 마이그레이션됩니다:
- riverpod
- riverpod_generator
class MyNotifier extends Notifier<int> {
int build() {
// 코드를 여기, 한곳에서만 읽고 쓰면 됩니다
final period = ref.watch(durationProvider);
final timer = Timer.periodic(period, (t) => update());
ref.onDispose(timer.cancel);
return 0;
}
Future<void> update() async {
final cancelToken = CancelToken();
ref.onDispose(cancelToken.cancel);
await ref.read(repositoryProvider).update(state + 1, token: cancelToken);
state++;
}
}
final myNotifierProvider = NotifierProvider<MyNotifier, int>(MyNotifier.new);
class MyNotifier extends _$MyNotifier {
int build() {
// 코드를 여기, 한곳에서만 읽고 쓰면 됩니다
final period = ref.watch(durationProvider);
final timer = Timer.periodic(period, (t) => update());
ref.onDispose(timer.cancel);
return 0;
}
Future<void> update() async {
final cancelToken = CancelToken();
ref.onDispose(cancelToken.cancel);
await ref.read(repositoryProvider).update(state + 1, token: cancelToken);
// `cancelToken.cancel`이 호출되면 커스텀 Exception이 발생합니다
state++;
}
}
상태 변경(mutation) API는 이전과 같습니다
지금까지 StateNotifier와 새 API의 차이점을 살펴봤습니다.
반면 Notifier, AsyncNotifier, StateNotifier에 공통된 점은 상태를
사용하고 변경하는 방식입니다.
consumer는 세 provider 모두에서 같은 문법으로 데이터를 가져올 수 있으므로,
StateNotifier에서 마이그레이션할 때 매우 편리합니다. notifier 메서드도 마찬가지입니다.
class SomeConsumer extends ConsumerWidget {
const SomeConsumer({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterNotifierProvider);
return Column(
children: [
Text("You've counted up until $counter, good job!"),
TextButton(
onPressed: () =>
ref.read(counterNotifierProvider.notifier).increment(),
child: const Text('Count even more!'),
),
],
);
}
}
기타 마이그레이션
StateNotifier와 Notifier(또는 AsyncNotifier) 사이의 비교적 영향이 적은 차이점들을 살펴보겠습니다.
.addListener와 .stream에서 마이그레이션
StateNotifier의 .addListener와 .stream은 상태 변화를 구독하는 데 사용할 수 있습니다.
이제 이 두 API는 구식으로 간주해야 합니다.
이는 Notifier, AsyncNotifier 및 다른 provider와 API를 완전히 통일하기 위한 의도적인 결정입니다.
실제로 Notifier나 AsyncNotifier를 사용하는 방식은 다른 provider와 다를 바가 없어야 합니다.
따라서 다음 코드는:
class MyNotifier extends StateNotifier<int> {
MyNotifier() : super(0);
void add() => state++;
}
final myNotifierProvider = StateNotifierProvider<MyNotifier, int>((ref) {
final notifier = MyNotifier();
final cleanup = notifier.addListener((state) => debugPrint('$state'));
ref.onDispose(cleanup);
// 또는 다음과 같이 쓸 수도 있습니다:
// final listener = notifier.stream.listen((event) => debugPrint('$event'));
// ref.onDispose(listener.cancel);
return notifier;
});
다음과 같이 바뀝니다:
- riverpod
- riverpod_generator
class MyNotifier extends Notifier<int> {
int build() {
listenSelf((_, next) => debugPrint('$next'));
return 0;
}
void add() => state++;
}
final myNotifierProvider = NotifierProvider<MyNotifier, int>(MyNotifier.new);
class MyNotifier extends _$MyNotifier {
int build() {
listenSelf((_, next) => debugPrint('$next'));
return 0;
}
void add() => state++;
}
요약하면, Notifier/AsyncNotifier를 구독하려면 ref.listen을 사용하면 됩니다.
자세한 내용은 Ref를 참고하세요.
테스트에서 .debugState 마이그레이션
StateNotifier는 .debugState를 노출합니다. 이 속성은 pkg:state_notifier 사용자가 개발 모드에서
테스트를 위해 클래스 바깥에서 상태에 접근할 수 있도록 하는 데 쓰입니다.
테스트에서 상태에 접근하려고 .debugState를 사용하고 있다면, 이 방식은 버려야 할 가능성이
높습니다.
Notifier / AsyncNotifier에는 .debugState가 없습니다. 대신 @visibleForTesting으로 지정된
.state를 직접 노출합니다.
테스트에서 .state에 접근하는 것은 피하세요. 꼭 필요하다면, Notifier / AsyncNotifier가 이미
제대로 인스턴스화되어 있는 경우에 한해서만 그렇게 하세요.
그런 경우라면 테스트 안에서 .state에 자유롭게 접근해도 됩니다.
실제로 Notifier / AsyncNotifier는 직접 인스턴스화해서는 안 되며, 반드시 해당 provider를
통해 다뤄야 합니다. 그렇지 않으면 ref와 family 인자가 초기화되지 않아
notifier가 제대로 동작하지 않습니다.
Notifier 인스턴스가 없나요?
문제없습니다. 노출된 상태를 읽을 때처럼 ref.read로 인스턴스를 얻을 수 있습니다:
void main(List<String> args) {
test('my test', () {
final container = ProviderContainer.test();
// notifier 가져오기
final Notifier<int> notifier = container.read(myNotifierProvider.notifier);
// 노출된 상태 가져오기
final int state = container.read(myNotifierProvider);
// TODO 테스트 작성하기
});
}
테스트에 대해서는 전용 가이드에서 더 알아보세요. provider 테스트하기를 참고하세요.
StateProvider에서 마이그레이션
StateProvider는 Riverpod 출시 때부터 제공되었으며, 단순한 형태의 StateNotifierProvider를
만들 때 코드 몇 줄을 줄이기 위해 만들어졌습니다.
StateNotifierProvider가 더 이상 사용되지 않으므로 StateProvider도 피해야 합니다.
게다가 현재 새 API에는 StateProvider에 해당하는 것이 없습니다.
그래도 StateProvider에서 Notifier로 마이그레이션하는 것은 간단합니다.
다음 코드는:
final counterProvider = StateProvider<int>((ref) {
return 0;
});
다음과 같이 바뀝니다:
- riverpod
- riverpod_generator
class CounterNotifier extends Notifier<int> {
int build() => 0;
set state(int newState) => super.state = newState;
int update(int Function(int state) cb) => state = cb(state);
}
final counterNotifierProvider =
NotifierProvider<CounterNotifier, int>(CounterNotifier.new);
class CounterNotifier extends _$CounterNotifier {
int build() => 0;
set state(int newState) => super.state = newState;
int update(int Function(int state) cb) => state = cb(state);
}
코드가 몇 줄 늘어나긴 하지만, StateProvider에서 벗어나면
StateNotifier를 완전히 정리할 수 있습니다.