주요 콘텐츠로 건너뛰기

`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을 정의하는 방법을 알아두세요.

부가 작업 수행(Performing side effects)를 참고하세요.

이전 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--;
}

NotifierStateNotifier를 비교하면 다음과 같은 주요 차이점을 확인할 수 있습니다:

  • 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)];
}

// ...
}

AsyncNotifierNotifier와 마찬가지로 더 간단하고 통일된 API를 제공합니다. 여기서 AsyncNotifier는 메서드가 있는 FutureProvider로 쉽게 볼 수 있습니다.

AsyncNotifer에는 StateNotifier에는 없는 유틸리티와 게터가 함께 제공됩니다, 예를 들어 futureupdate. 이를 통해 비동기 변이(mutations)와 부수작업(side-effects)을 처리할 때 훨씬 더 간단한 로직을 작성할 수 있습니다.

부가 작업 수행(Performing side effects)를 참고하세요.

StateNotifier<AsyncValue<T>>에서 AsyncNotifer<T>로 마이그레이션하는 방법은 다음과 같습니다:

  • 초기화 로직을 build에 넣기
  • 초기화 또는 부수작업 메서드에서 catch/try 블록을 제거합니다.
  • build에서 AsyncValue.guard를 제거합니다. FutureAsyncValue로 변환하기 때문입니다.

장점

이 몇 가지 예시를 살펴본 후, 이제 NotifierAsyncNotifer의 주요 장점을 살펴보겠습니다:

  • 새로운 구문은 특히 비동기 상태의 경우 훨씬 더 간단하고 가독성이 높아질 것입니다.
  • 새로운 API에는 일반적으로 상용구 코드가 줄어들 가능성이 높습니다.
  • 이제 작성하는 provider 타입에 관계없이 구문이 통합되어 코드 생성이 가능해졌습니다. (코드 생성(Code generation)에 대한 정보 참조).

더 자세히 살펴보고 더 많은 차이점과 유사점을 강조해 보겠습니다.

명시적인 .family.autoDispose 수정사항

또 다른 중요한 차이점은 새로운 API로 패밀리 및 자동폐기가 처리되는 방식입니다.

Notifier에는 FamilyNotifierAutoDisposeNotifier와 같은 자체 .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/AsyncNotifierStateNotifier의 수명 주기는 크게 다릅니다.

이 예시는 이전 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 콜백은 클래스에서 별도로 정의해야 합니다.
마지막으로, providerref.onDispose를 작성하는 것이 여전히 가능하기 때문에, 이 API의 로직이 얼마나 부족한지(sparse)를 다시 한 번 알 수 있습니다; 잠재적으로 개발자는 이 Notifier 동작을 이해하기 위해 여덟 곳(8개!)을 살펴봐야 할 수도 있습니다!

이러한 모호함은 Riverpod 2.0을 통해 해결되었습니다.

이전의 dispose vs ref.onDispose

StateNotifierdispose 메서드는 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)NotifierStateNotifier에서 사용할 수 있는 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!'),
)
],
);
}
}

기타 마이그레이션

StateNotifierNotifier(또는 AsyncNotifier)의 영향력이 크지 않은 차이점을 살펴봅시다.

.addListener.stream에서

StateNotifier.addListener.stream은 상태 변경을 수신하는 데 사용할 수 있습니다. 이 두 API는 이제 오래된 것으로 간주됩니다.

이는 Notifier, AsyncNotifier 및 기타 proviers와 완전한 API 통일성을 달성하기 위한 의도적인 것입니다.
실제로 NotifierAsyncNotifier를 사용하는 것은 다른 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)할 수 있습니다.