(Flutter) Riverpod의 Provider 사용 방법
이번 글에서는 Provider를 사용 방법에 대해 설명합니다.
ref
객체 가져오기
Provider를 사용하려면 먼저 ref
개체를 가져와야 합니다.
이 ref
객체를 통해 위젯 또는 다른 Provider와 상호 작용할 수 있습니다.
Provider로 부터 ref
객체 전달 받기
모든 Provider는 ref
객체를 파라미터로 전달 받습니다.
final valueProvider = Provider((ref) {
// 다른 Provider를 얻으려면 `ref`를 사용합니다.
final repository = ref.watch(repositoryProvider);
return repository.get();
});
이 파라미터 ref
객체는 다른 객체로 전달 할 수도 있습니다.
일반적인 사용 사례는 Provider의 "ref"를 StateNotifier
에 전달하는 것입니다.
final counterProvider = StateNotifierProvider<Counter, int>((ref) {
return Counter(ref);
});
class Counter extends StateNotifier<int> {
Counter(this.ref) : super(0);
final Ref ref;
void increment() {
// Counter는 `ref`를 사용하여 다른 Provider를 읽을 수 있습니다.
final repository = ref.read(repositoryProvider);
repository.post('...');
}
}
이렇게 하면 Counter
클래스에서 Provider를 읽을 수 있습니다.
위젯에서 ref
가져오기
위젯에는 기본적으로 ref
파라미터를 가지고 있지 않습니다. 그러나 Riverpod은 위젯에서 ref
를 얻을 수 있는 다양한 방법을 제공합니다.
StatelessWidget 대신 ConsumerWidget를 상속받기
위젯 트리에서 ref를 가져오려면 StatelessWidget 대신 ConsumerWidget를 사용하는 것입니다.
ConsumerWidget
은 StatelessWidget
과 거의 동일합니다. 차이점은 build 메소드의 두 번째 파라미터에 “ref” 객체가 있다는 점입니다.
ConsumerWidget
사용하는 예제는 다음과 같습니다.
class HomeView extends ConsumerWidget {
const HomeView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// `ref`를 사용하여 Provider를 수신합니다.
final counter = ref.watch(counterProvider);
return Text('$counter');
}
}
StatefulWidget+State 대신 ConsumerStateWidget+ConsumerState 상속받기
ConsumerWidget
과 마찬가지로, ConsumerStatefulWidget
+ConsumerState
는 StatefulWidget
+State
와 거의 동일합니다. 유일한 차이점은 State
에 “ref” 객체가 있다는 것입니다.
이번에는 "ref"가 빌드 메소드의 파라미터로 전달되지 않고 ConsumerState
객체의 프로퍼티로 전달됩니다:
class HomeView extends ConsumerStatefulWidget {
const HomeView({super.key});
@override
HomeViewState createState() => HomeViewState();
}
class HomeViewState extends ConsumerState<HomeView> {
@override
void initState() {
super.initState();
// ref"는 StatefulWidget의 모든 라이프사이클에서 사용할 수 있습니다.
ref.read(counterProvider);
}
@override
Widget build(BuildContext context) {
// 또한 "ref"를 사용하여 빌드 메소드 내에서 Provider를 수신 할 수 있습니다.
final counter = ref.watch(counterProvider);
return Text('$counter');
}
}
ref를 사용하여 Provider와 상호 작용하기
ref
의 주요 용도는 세 가지입니다:
ref.watch
: Provider의 값을 가져오고 변경 내용을 모니터링합니다. 이 값이 변경되면 해당 값을 구독하고 있는 위젯이나 Provider를 다시 빌드합니다.ref.listen
: Provider에 리스너를 추가하여 해당 Provider가 변경될 때마다 새 페이지로 이동하거나 모달을 표시하는 등의 작업을 수행할 수 있습니다.ref.read
: 변경 내용을 무시하면서(모니터링하지 않음) Provider의 값을 가져옵니다. 이는 "on click"과 같은 클릭 이벤트에서 변경 내용을 무시하면서 Provider의 값이 필요할 때 유용합니다.
[!info] 메모
가능하면 기능을 구현할 때는ref.read
나ref.listen
보다ref.watch
를 사용하는 것이 좋습니다.
ref.watch
를 사용하면 앱이 reactive(리엑티브)와 declarative(선언형)에 가까워지고 유지 보수가 더 쉬워집니다.
ref.watch
를 사용하여 Provider를 모니터링하기
ref.watch
는 위젯의 build
메서드 내부 또는 Provider 내부에서 Provider를 수신(listen)하도록 하는 데 사용됩니다.
예를 들어, Provider는 ref.watch
를 사용하여 여러 Provider를 결합하여 새로운 값을 생성 할 수 있습니다.
예를 들어 할 일 목록을 필터링할 수 있습니다. 두 개의 공급자가 있을 수 있습니다:
이 기술을 사용하면 할일 목록을 필터링할 수 있습니다. 예를 들어 Todo 앱에 다음과 같은 Provider가 있다고 가정합니다.
filterTypeProvider
, 현재 필터 유형(없음, 완료된 할일만 표시, …)을 가지는 Provider`todosProvider
: 전체 할일 목록을 가지는 Provider
그리고ref.watch
를 사용하면 두 Provider를 결합하여 필터링된 작업 목록을 생성하는 세 번째 Provider를 만들 수 있습니다:
final filterTypeProvider = StateProvider<FilterType>((ref) => FilterType.none);
final todosProvider =
StateNotifierProvider<TodoList, List<Todo>>((ref) => TodoList());
final filteredTodoListProvider = Provider((ref) {
// 할일(todos)목록과 필터(filter) 상태 값을 모두 가져옵니다..
final FilterType filter = ref.watch(filterTypeProvider);
final List<Todo> todos = ref.watch(todosProvider);
switch (filter) {
case FilterType.completed:
// 완료된(completed) 할일 목록을 반환합니다.
return todos.where((todo) => todo.isCompleted).toList();
case FilterType.none:
// 필터링되지 않은 할일 목록을 반환합니다.
return todos;
}
});
위 코드에서 filteredTodoListProvider
는 필터링된 할일 목록을 가지고 있습니다.
필터나 할일 목록이 변경되면 필터링된 할일 목록도 자동으로 업데이트됩니다. 동시에 필터나 할일 목록이 모두 변경되지 않으면 필터링된 할일 목록은 다시 계산되지 않습니다.
마찬가지로 위젯은 ref.watch
를 사용하여 프로바이더의 콘텐츠를 표시하고 해당 콘텐츠가 변경될 때마다 UI를 업데이트할 수 있습니다.
final counterProvider = StateProvider((ref) => 0);
class HomeView extends ConsumerWidget {
const HomeView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// `ref`를 사용하여 counterProvider 수신하기.
final counter = ref.watch(counterProvider);
return Text('$counter');
}
}
위 샘플 코드는 counter 숫자를 관리하는 counterProvider
를 모니터링합니다. counterProvider
값이 변경되면 위젯이 업데이트되어 새로운 값을 표시하도록 UI를 업데이트합니다.
[!warning] 주의
watch
메서드는 비동기적(asynchronously)으로 호출해서는 안 됩니다. 예를 들면 ElevatedButton의onPressed
함수 내부에서, 그리고initState
와 다른 State의 생명주기 내부에서도watch
메서드를 사용해서는 안됩니다.이러한 경우에는
ref.read
를 사용하는 것이 좋습니다.
ref.listen
을 사용하여 Provider 변화에 반응하기
ref.watch
와 유사하게 Provider를 관찰하기 위해 ref.listen
을 사용할 수 있습니다.
ref.watch
와 ref.listen
의 큰 차이점은 ref.watch
는 Provider 상태값이 변경되면 widget/provider을 다시 빌드하지만 ref.listen
은 사용자 정의 함수를 호출한다는 점입니다.
예를 들어 에러가 발생할때 스낵바(snackbar)를 표시하거나 어떠한 반응의 변화에 대응해야 할때 유용하게 사용할 수 있습니다.
ref.listen
메소드는 2개의 위치 인수(positional arguments)가 필요합니다. 첫번째 인수는 Provider이고 두번째 인수는 상태가 변경될 때 실행되는 콜백 함수입니다. 콜백 함수가 호출되면 이전 상태의 값과 새 상태의 값, 두 개의 값이 전달됩니다.
ref.listen
메소드는 Provider 내부에서 사용할 수 있습니다. 아래의 코드를 확인해 볼 수 있습니다.
final counterProvider = StateNotifierProvider<Counter, int>(Counter.new);
final anotherProvider = Provider((ref) {
ref.listen<int>(counterProvider, (int? previousCount, int newCount) {
print('The counter changed $newCount');
});
// ...
});
위젯의 build
메서드 내부에서도 사용할 수 있습니다.
final counterProvider =
StateNotifierProvider<Counter, int>(Counter.new);
class HomeView extends ConsumerWidget {
const HomeView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen<int>(counterProvider, (int? previousCount, int newCount) {
print('The counter changed $newCount');
});
return Container();
}
}
[!warning] 주의
listen
메서드는 비동기적으로 호출해서는 안 됩니다. 예를 들어ElevatedButton
의onPressed
내부에서 호출해서는 안 됩니다. 또한initState
및 기타 상태 수명 주기 내부에서도 사용해서는 안 됩니다.
ref.read
를 사용하여 Provider의 상태 가져오기
ref.read
메소드는 Provider를 수신하지 않고도 Provider를 가져오는 방법입니다.
ref.read
는 일반적으로 사용자 상호작용으로 발생가능한 트리거 함수 내부에서 주로 사용합니다. 예를 들어 사용자가 버튼을 눌렀을 때 ref.read
메소드를 사용하여 카운터의 값을 증가시킬 수 있습니다.
final counterProvider =
StateNotifierProvider<Counter, int>(Counter.new);
class HomeView extends ConsumerWidget {
const HomeView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () {
// `Counter`클래스의 `increment()` 메소드를 호출합니다.
ref.read(counterProvider.notifier).increment();
},
),
);
}
}
[!note] 노트
ref.read
는 반응형이 아니므로 가급적 사용하지 않는 것이 좋습니다.
ref.read
메소드는watch
또는listen
를 사용하면 문제가 발생할 수 있는 경우를 위해 존재합니다. 가능하면 거의 항상watch
/listen
을 사용하는 것이 좋으며, 특히watch
를 사용하는 것이 좋습니다.
[중요] ref.read
를 build
메소드 내부에서 사용하지 마세요.
ref.read
을 사용하여 위젯 성능을 최적화하고 싶을 수도 있습니다.
final counterProvider = StateProvider((ref) => 0);
Widget build(BuildContext context, WidgetRef ref) {
// provider의 업데이트를 무시하기 위해 `ref.read` 메소드를 사용
final counter = ref.read(counterProvider.notifier);
return ElevatedButton(
onPressed: () => counter.state++,
child: const Text('button'),
);
}
그러나 이는 잘못된 사용 사례이며 추적하기 어려운 버그의 원인이 될 수 있습니다.
이러한 방식으로 ref.read
를 사용하는 것은 일반적으로 "Provider가 노출한 값은 절대 변경되지 않으므로 'ref.read’를 사용하는 것이 안전하다"라는 생각과 관련이 있습니다. 이 가정의 문제점은 오늘은 해당 Provider가 실제로 값을 업데이트하지 않을 수도 있지만, 내일도 마찬가지일 것이라고는 보장할 수 없다는 것입니다.
소프트웨어는 자주 변경되는 경향이 있으며, 앞으로는 이전에는 변경되지 않았던 값도 변경해야 할 가능성이 높습니다.
ref.read
를 사용하는 경우 해당 값을 변경해야 할 때 전체 코드베이스를 검토하여 ref.read
를 ref.watch
로 변경해야 합니다. 이로 인해 오류가 발생하기 쉽고 일부 변경 사항을 잊어버릴 가능성이 높습니다.
반면에 처음부터 ref.watch
를 사용하면 리팩토링할 때 발생하는 문제를 줄일 수 있습니다.
그래도 나는 ref.read
를 사용하여 위젯이 다시 빌드되는 횟수를 줄이고 싶습니다.
목표는 칭찬할 만하지만, ref.watch
를 사용해도 똑같은 효과(빌드 횟수 감소)를 얻을 수 있습니다.
Provider는 리빌드 횟수를 줄이면서 값을 얻을 수 있는 다양한 방법을 제공합니다.
예를 들면:
final counterProvider = StateProvider((ref) => 0);
Widget build(BuildContext context, WidgetRef ref) {
StateController<int> counter = ref.read(counterProvider.notifier);
return ElevatedButton(
onPressed: () => counter.state++,
child: const Text('button'),
);
}
위의 코드 대신에 아래처럼 사용하는 것이 좋습니다.
final counterProvider = StateProvider((ref) => 0);
Widget build(BuildContext context, WidgetRef ref) {
StateController<int> counter = ref.watch(counterProvider.notifier);
return ElevatedButton(
onPressed: () => counter.state++,
child: const Text('button'),
);
}
두 코드 모두 카운터가 증가할 때 버튼 위젯은 다시 빌드되지 않습니다.
하지만 카운터가 리셋되는 경우에는 두 번째 방식을 사용 할 수 있습니다. 예를 들어 앱의 다른 부분에서 호출할 수 있습니다:
ref.refresh(counterProvider);
Counter
객체가 다시 생성됩니다.
여기서 ref.read
를 사용하면 버튼은 더 이상 사용되지 않는 이전 Counter
인스턴스를 계속 사용하게 됩니다. 반면에 ref.watch
를 사용하면 버튼은 올바르게 다시 빌드되어 새로운 Counter
를 사용하게 됩니다.
무엇을 읽을지 결정하기
구독하려는 Provider에 따라 구독 할 수 있는 여러 값이 있을 수 있습니다.
예를 들면, 다음 StreamProvider 살펴보세요.
final userProvider = StreamProvider<User>(...);
이 userProvider
를 읽으려고 할때 다음과 같이 사용할 수 있습니다.
userProvider
자체를 구독(listening)하여 동기적(synchronously)으로 현재 상태 값을 읽을 수 있습니다.
Widget build(BuildContext context, WidgetRef ref) {
AsyncValue<User> user = ref.watch(userProvider);
return user.when(
loading: () => const CircularProgressIndicator(),
error: (error, stack) => const Text('Oops'),
data: (user) => Text(user.name),
);
}
userProvider.stream
을 구독하여 연결된 Stream을 가져올 수 있습니다.
Widget build(BuildContext context, WidgetRef ref) {
Stream<User> user = ref.watch(userProvider.stream);
}
userProvider.future
를 구독하여 가장 최근 상태값을 가진 Future를 가져올 수 있습니다.
Widget build(BuildContext context, WidgetRef ref) {
Future<User> user = ref.watch(userProvider.future);
}
다른 Provider들은 또 다른 대체 값들(alternative values)을 제공할 수 있습니다. 자세한 내용은 API reference를 참조하여 있는 각 Provider의 설명을 참고하세요.
"select"를 사용하여 리빌드 필터링하기
마지막으로 providers 관련하여 언급할 기능은 위젯/프로바이더가 ref.watch
에서 리빌드하는 횟수 또는 ref.listen
이 함수를 실행하는 빈도를 줄일 수 있는 기능입니다.
기본적으로 provider를 감시하는 것은 전체 객체 상태를 감시하는 것이므로 이를 명심해야 합니다. 그러나 때로는 위젯/프로바이더가 전체 객체가 아닌 일부 프로퍼티의 변경 사항에만 관심을 가질 수도 있습니다.
예를 들어, 프로바이더가 다음 User
상태값을 가진다고 가정해 봅시다.
abstract class User {
String get name;
int get age;
}
그런데 위젯에서는 단순히 User
의 name
값만 사용하고 있습니다.
Widget build(BuildContext context, WidgetRef ref) {
User user = ref.watch(userProvider);
return Text(user.name);
}
순진하게 ref.watch
를 사용하게 되면 User
의 age
속성이 변경될 때마다 위젯이 다시 빌드됩니다.
해결 방법은 select
를 사용하는 것입니다. Riverpod에 User
의 특정 속성만 관찰하고 싶다고 명시적으로 알리는 것입니다.
코드를 다음과 같이 개선해 볼 수 있습니다.
Widget build(BuildContext context, WidgetRef ref) {
String name = ref.watch(userProvider.select((user) => user.name));
return Text(name);
}
select
를 사용하면 관심 있는 속성을 반환하는 함수를 지정할 수 있습니다.
User
값이 변경될 때마다 Riverpod은 이 함수를 호출하여 이전 값과 새로운 값을 비교합니다. 만약 두 결과가 다른 경우(예: name
이 변경된 경우) Riverpod는 위젯을 다시 빌드합니다.
그러나 두 값이 같으면(예: age
만 변경된 경우) Riverpod은 위젯을 다시 빌드하지 않습니다.
[!info] 정보
select
는ref.listen
과 함께 사용할 수도 있습니다.ref.listen<String>( userProvider.select((user) => user.name), (String? previousName, String newName) { print('The user name changed $newName'); } );
이렇게 하면
name
이 변경되었을 때만 리스너를 호출합니다.
[!tip] 팁
select
는 반드시 객체의 속성을 반환할 필요는 없습니다. ==연산자를 오버라이드(overrides)하는 모든 값에 작동합니다. 예를 들면 다음과 같이 할 수 있습니다.final label = ref.watch(userProvider.select((user) => 'Mr ${user.name}'));
or