`StateNotifier`에서
Riverpod 2.0과 함께 새로운 클래스가 도입되었습니다: Notifier
/ AsyncNotifer
.
이제 이러한 새로운 API를 위해 StateNotifier
는 더 이상 사용되지 않습니다.
이 페이지는 더 이상 사용되지 않는 StateNotifier
에서 새로운 API로 마이그레이션하는 방법을 보여줍니다.
AsyncNotifier
가 도입한 주요 이점은 더 나은 async
지원입니다,
실제로 AsyncNotifier
는 UI에서 수정할 수 있는 방법을 노출하는 FutureProvider
로 생각할 수 있습니다.
또한, 새로운 (Async)Notifier
가 추가되었습니다:
- 클래스 내부에
Ref
객체 노출하기 - 코드 생성 방식(codegen)과 비코드 생성 방식(non-codegen) 간에 유사한 문법 제공
- 동기화 버전과 비동기 버전 간에 유사한 문법 제공
- 로직을 provider에서 벗어나 Notifiers 자체로 중앙 집중화하기
Notifier
를 정의하는 방법, StateNotifier
와 비교하는 방법, 비동기 상태를 위해 새로운 AsyncNotifier
를 마이그레이션하는 방법을 살펴봅시다.
새로운 문법 비교
이 비교를 시작하기 전에 Notifier
을 정의하는 방법을 알아두세요.
이전 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로 작성된 동일한 예시이며, 대략 다음과 같이 변환됩니다:
class CounterNotifier extends _$CounterNotifier {
int build() => 0;
void increment() => state++;
void decrement() => state--;
}
Notifier
와 StateNotifier
를 비교하면 다음과 같은 주요 차이점을 확인할 수 있습니다:
StateNotifier
의 반응형 종속성(reactive dependencies)은 provider에서 선언되는 반면,Notifier
는 이 로직을build
메서드에서 중앙 집중화합니다.StateNotifier
의 전체 초기화 프로세스는 provider와 생성자 사이에 분할되어 있는 반면,Notifier
는 이러한 로직을 배치할 수 있는 단일 위치를 예약합니다.StateNotifier
와는 반대로,Notifier
의 생성자에는 어떠한 로직도 작성되지 않는 것을 주목하세요.
Notifier
의 비동기 대응 클래스인 AsyncNotifer
에서도 비슷한 점을 발견할 수 있습니다.
비동기 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를 사용하여 재작성된 위의 예시입니다:
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
로 쉽게 볼 수 있습니다.
AsyncNotifer
에는 StateNotifier
에는 없는 유틸리티와 게터가 함께 제공됩니다,
예를 들어 future
및 update
.
이를 통해 비동기 변이(mutations)와 부수작업(side-effects)을 처리할 때 훨씬 더 간단한 로직을 작성할 수 있습니다.
StateNotifier<AsyncValue<T>>
에서 AsyncNotifer<T>
로 마이그레이션하는 방법은 다음과 같습니다:
- 초기화 로직을
build
에 넣기 - 초기화 또는 부수작업 메서드에서
catch
/try
블록을 제거합니다. build
에서AsyncValue.guard
를 제거합니다.Future
를AsyncValue
로 변환하기 때문입니다.
장점
이 몇 가지 예시를 살펴본 후, 이제 Notifier
와 AsyncNotifer
의 주요 장점을 살펴보겠습니다:
- 새로운 구문은 특히 비동기 상태의 경우 훨씬 더 간단하고 가독성이 높아질 것입니다.
- 새로운 API에는 일반적으로 상용구 코드가 줄어들 가능성이 높습니다.
- 이제 작성하는 provider 타입에 관계없이 구문이 통합되어 코드 생성이 가능해졌습니다. (코드 생성(Code generation)에 대한 정보 참조).
더 자세히 살펴보고 더 많은 차이점과 유사점을 강조해 보겠습니다.
명시적인 .family
및 .autoDispose
수정사항
또 다른 중요한 차이점은 새로운 API로 패밀리 및 자동폐기가 처리되는 방식입니다.
Notifier
에는 FamilyNotifier
및 AutoDisposeNotifier
와 같은 자체 .family
및 .autoDispose
대응 항목이 있습니다.
항상 그렇듯이, 이러한 수정 사항을 결합할 수 있습니다 (일명 AutoDisposeFamilyNotifier
).
AsyncNotifer
에는 비동기 버전도 있습니다(예: AutoDisposeFamilyAsyncNotifier
).
수정 사항(Modifications)은 클래스 내부에 명시적으로 지정(stated)됩니다;
모든 매개변수는 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, int, String>((ref, id) {
return BugsEncounteredNotifier(ref: ref, featureId: id);
});
BugsEncounteredNotifier
은 무겁거나 읽기 어려운 느낌입니다.
마이그레이션 된 AsyncNotifier
를 살펴 보겠습니다:
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의 로직이 얼마나 부족한지(sparse)를 다시 한 번 보여줍니다:
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
를 페기(dispose): 인스턴스가 다시 인스턴스화되고 내부 상태가 다시 초기화됩니다.
또한 다른 모든 provider와 달리 dispose
콜백은 클래스에서 별도로 정의해야 합니다.
마지막으로, provider에 ref.onDispose
를 작성하는 것이 여전히 가능하기 때문에, 이 API의 로직이 얼마나 부족한지(sparse)를 다시 한 번 알 수 있습니다;
잠재적으로 개발자는 이 Notifier 동작을 이해하기 위해 여덟 곳(8개!)을 살펴봐야 할 수도 있습니다!
이러한 모호함은 Riverpod 2.0
을 통해 해결되었습니다.
이전의 dispose
vs ref.onDispose
StateNotifier
의 dispose
메서드는 notifier 자체의 폐기(dispose) 이벤트를 참조하며, 일명 자신을 처분하기 전에(before disposing of itself) 호출되는 콜백입니다.
(Async)Notifier
은 이 속성을 갖지 않는데, 리빌드 시 폐기되지 않고 내부 상태만 폐기되기 때문입니다.
새로운 notifiers에서 폐기 수명주기는 다른 provider와 마찬가지로 ref.onDispose
(및 기타)를 통해 한 곳에서만 처리됩니다.
이렇게 하면 API와 DX가 단순화되어 라이프사이클 부작용을 이해하기 위해 살펴봐야 할 곳이 build
메서드 하나만 남게 됩니다.
간단히 말해서, 내부 상태(internal state)가 다시 빌드되기 전에 실행되는 콜백을 등록하려면 다른 모든 provider와 마찬가지로 ref.onDispose
를 사용하면 됩니다.
위의 스니펫을 다음과 같이 마이그레이션할 수 있습니다:
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++; // throw될 수도 있습니다.
}
}
이 마지막 스니펫에는 확실히 약간의 단순화가 있지만 여전히 열려 있는 문제가 있습니다:
이제 update
를 수행하는 동안 notifiers가 아직 살아(alive)있는지 여부를 파악할 수 없습니다.
이로 인해 원치 않는 StateError
가 발생할 수 있습니다.
더 이상 마운트되지(mounted)
않음
이는 (Async)Notifier
에 StateNotifier
에서 사용할 수 있는 mounted
프로퍼티가 없기 때문에 발생합니다.
수명 주기의 차이를 고려하면 이것은 완벽하게 이해가 됩니다;
가능하긴 하지만, 새로운 notifiers에서 mounted
프로퍼티는 오해의 소지가 있습니다: mounted
는 거의 항상 true
이 될 것입니다.
커스텀 해결방법을 만들 수는 있지만, 비동기 작업을 취소하여 이 문제를 해결하는 것이 좋습니다.
작업 취소는 커스텀 Completer 또는 커스텀 파생어(derivative)를 사용하여 수행할 수 있습니다.
예를 들어 Dio
를 사용하여 네트워크 요청을 수행하는 경우 cancel token을 사용하는 것이 좋습니다.
(캐시 지우기 및 상태 폐기(disposal)에 반응하기 참고)
따라서 위의 예는 다음과 같이 마이그레이션됩니다:
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);
// 취소 토큰이 호출되면 사용자 정의 예외가 발생합니다.
state++;
}
}
변이(Mutations) API는 이전과 동일합니다
지금까지 StateNotifier
와 새로운 API의 차이점을 살펴보았습니다.
대신, Notifier
, AsyncNotifer
, StateNotifier
가 공유하는 한 가지는 상태를 소비하고 변경할 수 있다는 점입니다.
Consumers는 동일한 구문으로 이 세 providers로부터 데이터를 얻을 수 있습니다,
이는 StateNotifier
에서 마이그레이션하는 경우에 유용하며, 이는 notifiers 메서드에도 적용됩니다.
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
및 기타 proviers와 완전한 API 통일성을 달성하기 위한 의도적인 것입니다.
실제로 Notifier
나 AsyncNotifier
를 사용하는 것은 다른 provier와 다르지 않아야 합니다.
따라서 이 것이:
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;
});
이렇게 됩니다:
class MyNotifier extends _$MyNotifier {
int build() {
listenSelf((_, next) => debugPrint('$next'));
return 0;
}
void add() => state++;
}
간단히 말해, Notifier
/AsyncNotifer
를 수신하려면 ref.listen
를 사용하면 됩니다.
테스트의 .debugState
에서
StateNotifier
는 .debugState
를 노출합니다:
이 프로퍼티는 개발 모드에서 테스트 목적으로 클래스 외부에서 상태 액세스를 활성화하기 위해 pkg:state_notifier 사용자가 사용할 수 있습니다.
테스트에서 상태에 액세스하기 위해 .debugState
를 사용하는 경우 이 접근 방식을 중단해야 합니다.
Notifier
/ AsyncNotifer
에는 .debugState
가 없으며, 대신 .state
, 즉 @visibleForTesting
을 직접 노출합니다.
AVOID 테스트에서 .state
에 접근하지 마시고, 꼭 접근해야 한다면 Notifier
/ AsyncNotifer
가 이미 제대로 인스턴스화된 경우에만 접근하세요;
그러면 테스트 내부에서 .state
에 자유롭게 접근할 수 있습니다.
실제로 Notifier
/ AsyncNotifier
는 직접 인스턴스화해서는 안 됩니다;
대신 해당 provider를 사용해 상호작용해야 합니다:
그렇게 하지 않으면 notifier가 중단(break)됩니다,
ref와 family 인자가 초기화되지 않기 때문입니다.
Notifier
인스턴스가 없으신가요?
문제없습니다. 노출된 상태를 읽을 때와 마찬가지로 ref.read
로 인스턴스를 가져올 수 있습니다:
void main(List<String> args) {
test('my test', () {
final container = ProviderContainer();
addTearDown(container.dispose);
// notifier 가져오기
final AutoDisposeNotifier<int> notifier = container.read(myNotifierProvider.notifier);
// 노출된 상태 가져오기
final int state = container.read(myNotifierProvider);
// TODO 테스트 작성하기
});
}
전용 가이드에서 테스트에 대해 자세히 알아보세요. providers 테스트하기를 참고하세요.
StateProvider
에서
StateProvider
는 Riverpod에서 출시 이후 노출된 것으로, StateNotifierProvider
의 간소화된 버전을 위해 몇 가지 LoC를 절약하기 위해 만들어졌습니다.
StateNotifierProvider
는 더 이상 사용되지 않으므로 StateProvider
도 피해야 합니다.
또한 현재는 새로운 API에 상응하는 StateProvider
가 없습니다.
그럼에도 불구하고 StateProvider
에서 Notifier
로 마이그레이션하는 것은 간단합니다.
이 코드는:
final counterProvider = StateProvider<int>((ref) {
return 0;
});
이렇게 됩니다:
class CounterNotifier extends _$CounterNotifier {
int build() => 0;
set state(int newState) => super.state = newState;
int update(int Function(int state) cb) => state = cb(state);
}
LoC가 몇 개 더 들더라도 StateProvider
에서 마이그레이션하면 StateNotifier
를 확실하게 보존(archive)할 수 있습니다.