`ChangeNotifier`에서 마이그레이션하기
Riverpod에서 ChangeNotifierProvider는 pkg:provider에서 매끄럽게 넘어올 수 있도록
제공되는 것입니다.
pkg:riverpod로의 마이그레이션을 막 시작했다면, 전용 가이드를 꼭 읽어 보세요
(빠른 시작 참고).
이 글은 이미 riverpod로 옮겨 왔지만 ChangeNotifier에서 벗어나고 싶은 분들을 위한 것입니다.
결론부터 말하면, ChangeNotifier에서 AsyncNotifier로 마이그레이션하려면
사고방식을 바꿔야 하지만, 그 결과 마이그레이션된 코드는 훨씬 단순해집니다.
다음 (문제가 있는) 예제를 살펴봅시다.
class MyChangeNotifier extends ChangeNotifier {
MyChangeNotifier() {
_init();
}
List<Todo> todos = [];
bool isLoading = true;
bool hasError = false;
Future<void> _init() async {
try {
final json = await http.get('api/todos');
todos = [...json.map(Todo.fromJson)];
} on Exception {
hasError = true;
} finally {
isLoading = false;
notifyListeners();
}
}
Future<void> addTodo(int id) async {
isLoading = true;
notifyListeners();
try {
final json = await http.post('api/todos');
todos = [...json.map(Todo.fromJson)];
hasError = false;
} on Exception {
hasError = true;
} finally {
isLoading = false;
notifyListeners();
}
}
}
final myChangeProvider = ChangeNotifierProvider<MyChangeNotifier>((ref) {
return MyChangeNotifier();
});
이 구현에는 다음과 같은 좋지 않은 설계가 여럿 보입니다.
- 여러 비동기 상황을 처리하기 위해
isLoading과hasError를 사용합니다 - 번거로운
try/catch/finally구문으로 요청을 조심스럽게 처리해야 합니다 - 구현이 제대로 동작하려면 적절한 시점에
notifyListeners를 호출해야 합니다 - 빈 리스트로 초기화하는 것처럼, 일관성이 없거나 바람직하지 않을 수 있는 상태가 존재합니다
이 예제는 ChangeNotifier가 초보 개발자를 잘못된 설계로 이끌 수 있음을 보여 주기 위해
일부러 만든 것입니다. 또 하나 얻을 수 있는 교훈은, 가변 상태가 처음 기대했던 것보다
훨씬 다루기 어려울 수 있다는 점입니다.
Notifier/AsyncNotifier를 불변 상태와 함께 사용하면 더 나은 설계를 할 수 있고
오류도 줄일 수 있습니다.
위 코드를 한 단계씩 최신 API로 마이그레이션하는 방법을 살펴보겠습니다.
마이그레이션 시작하기
먼저 새 provider / notifier를 선언해야 합니다. 이는 각자의 비즈니스 로직에 따라 어느 정도 고민이 필요한 부분입니다.
위 요구 사항을 정리해 보면 다음과 같습니다.
- 상태는 매개변수 없는 네트워크 호출로 얻는
List<Todo>로 표현됩니다 - 상태는
loading,error,data상태에 대한 정보도 노출해야 합니다 - 상태는 노출된 몇몇 메서드를 통해 변경될 수 있으므로, 함수만으로는 부족합니다
위의 고민은 결국 다음 질문에 답하는 것으로 정리됩니다.
- 부수 효과가 필요한가요?
y: riverpod의 클래스 기반 API를 사용합니다n: riverpod의 함수 기반 API를 사용합니다
- 상태를 비동기로 불러와야 하나요?
y:build가Future<T>를 반환하게 합니다n:build가 그냥T를 반환하게 합니다
- 매개변수가 필요한가요?
y:build(또는 함수)가 매개변수를 받게 합니다n:build(또는 함수)가 추가 매개변수를 받지 않게 합니다
코드 생성을 사용한다면 위의 고민만으로 충분합니다.
알맞은 클래스 이름이나 각 클래스의 고유한 API를 고민할 필요가 없습니다.
@riverpod는 반환 타입과 함께 클래스를 작성하기만 하면 되니, 바로 시작할 수 있습니다.
기술적으로 여기서 가장 적합한 선택은 위의 요구 사항을 모두 만족하는
AsyncNotifier<List<Todo>>를 정의하는 것입니다. 먼저 의사 코드를 작성해 봅시다.
- riverpod
- riverpod_generator
class MyNotifier extends AsyncNotifier<List<Todo>> {
FutureOr<List<Todo>> build() {
// TODO ...
return [];
}
Future<void> addTodo(Todo todo) async {
// TODO
}
}
final myNotifierProvider =
AsyncNotifierProvider.autoDispose<MyNotifier, List<Todo>>(MyNotifier.new);
class MyNotifier extends _$MyNotifier {
FutureOr<List<Todo>> build() {
// TODO ...
return [];
}
Future<void> addTodo(Todo todo) async {
// TODO
}
}
IDE의 스니펫을 활용하면 도움을 받거나 코드를 더 빨리 작성할 수 있습니다. 자세한 내용은 시작하기를 참고하세요.
ChangeNotifier 구현과 비교하면 이제 todos를 선언할 필요가 없습니다.
그 역할은 build로 암묵적으로 불러오는 state가 맡습니다.
실제로 riverpod의 notifier는 한 번에 하나의 엔티티만 노출할 수 있습니다.
Riverpod의 API는 세분화된 사용을 전제로 설계되었습니다. 그래도 마이그레이션하는 동안에는 여러 값을 담는 사용자 정의 엔티티를 정의할 수 있습니다. 처음에는 Dart 3의 레코드를 활용해 마이그레이션을 수월하게 진행하는 것을 고려해 보세요.
초기화
notifier를 초기화하는 방법은 간단합니다. build 안에 초기화 로직을 작성하기만 하면 됩니다.
이제 기존 _init 함수는 없애도 됩니다.
- riverpod
- riverpod_generator
class MyNotifier extends AsyncNotifier<List<Todo>> {
FutureOr<List<Todo>> build() async {
final json = await http.get('api/todos');
return [...json.map(Todo.fromJson)];
}
}
final myNotifierProvider =
AsyncNotifierProvider.autoDispose<MyNotifier, List<Todo>>(MyNotifier.new);
class MyNotifier extends _$MyNotifier {
FutureOr<List<Todo>> build() async {
final json = await http.get('api/todos');
return [...json.map(Todo.fromJson)];
}
}
기존 _init과 비교해도 새 build에서 빠진 것은 없습니다. 이제 isLoading이나 hasError 같은
변수를 초기화할 필요가 없습니다.
Riverpod은 모든 비동기 provider를 AsyncValue<List<Todo>>로 노출해 자동으로 변환하며,
단순한 boolean 플래그 두 개보다 훨씬 더 잘 비동기 상태의 복잡한 부분을 처리합니다.
실제로 AsyncNotifier를 사용하면, 비동기 상태를 처리하기 위해 try/catch/finally를
추가로 작성하는 것은 사실상 안티 패턴이 됩니다.
Mutation과 부수 효과
초기화와 마찬가지로, 부수 효과를 수행할 때도 hasError 같은 boolean 플래그를 조작하거나
try/catch/finally 블록을 추가로 작성할 필요가 없습니다.
아래는 보일러플레이트를 모두 걷어 내고 위 예제를 완전히 마이그레이션한 결과입니다.
- riverpod
- riverpod_generator
class MyNotifier extends AsyncNotifier<List<Todo>> {
FutureOr<List<Todo>> build() async {
final json = await http.get('api/todos');
return [...json.map(Todo.fromJson)];
}
Future<void> addTodo(Todo todo) async {
// optional: state = const AsyncLoading();
final json = await http.post('api/todos');
final newTodos = [...json.map(Todo.fromJson)];
state = AsyncData(newTodos);
}
}
final myNotifierProvider =
AsyncNotifierProvider.autoDispose<MyNotifier, List<Todo>>(MyNotifier.new);
class MyNotifier extends _$MyNotifier {
FutureOr<List<Todo>> build() async {
final json = await http.get('api/todos');
return [...json.map(Todo.fromJson)];
}
Future<void> addTodo(Todo todo) async {
// optional: state = const AsyncLoading();
final json = await http.post('api/todos');
final newTodos = [...json.map(Todo.fromJson)];
state = AsyncData(newTodos);
}
}
문법이나 설계 방식은 달라질 수 있지만, 결국 요청을 작성하고 그 뒤에 상태를 갱신하기만 하면 됩니다. 자세한 내용은 Provider를 참고하세요.
마이그레이션 과정 요약
위에서 진행한 마이그레이션 과정 전체를 작업 관점에서 다시 정리해 봅시다.
- 생성자에서 호출하던 사용자 정의 메서드에 있던 초기화 로직을
build로 옮겼습니다 todos,isLoading,hasError프로퍼티를 제거했습니다. 내부state로 충분합니다try-catch-finally블록을 모두 제거했습니다. future를 반환하는 것으로 충분합니다- 부수 효과(
addTodo)에도 같은 단순화를 적용했습니다 state를 다시 할당하는 방식으로 변경을 적용했습니다