뮤테이션
뮤테이션은 새 할 일처럼 바뀐 내용을 서버에 보내요. 버튼에서 뮤테이션을 실행하고, 진행 상태를 어느 화면에서든 보여주세요. 실패하면 사용자에게 알리고, 서버가 응답하기 전에 캐시를 업데이트하세요.
이 뮤테이션은 할 일을 추가해요.
final addTodo = Mutation( mutationKey: const ['todos', 'add'], mutationFn: (String title) => api.addTodo(title), onSuccess: (todo, title, context, client) { return client.invalidateQueries(queryKey: ['todos']); },);mutationFn의 매개변수에 위 코드의 String title처럼 타입을 적으세요. 그러면 Dart가 그 타입에서 나머지 타입을 추론해요. 어떤 위젯이든 mutationKey로 뮤테이션의 실행을 찾을 수 있어요. 실행은 mutate 호출 한 번이에요(뮤테이션 실행). 모든 옵션은 뮤테이션 옵션에 있어요.
뮤테이션 실행하기
섹션 제목: “뮤테이션 실행하기”정의의 mutate를 호출하고 context.queryClient를 넘기세요. StatelessWidget을 포함해 어떤 위젯이든 이렇게 뮤테이션을 실행해요.
class AddTodoButton extends StatelessWidget { const AddTodoButton({super.key});
@override Widget build(BuildContext context) { return FilledButton( onPressed: () => addTodo.mutate('Buy milk', context.queryClient), child: const Text('Add'), ); }}mutate는 실행을 시작하고 바로 반환해요. 실행이 실패하면 에러는 호출한 쪽이 아니라 실행의 상태와 콜백으로 전달돼요.- 정의는 상태를 담지 않아요. 실행은 위젯이 아니라 클라이언트의 캐시에 속해요. 그래서 버튼이 화면에서 사라져도 실행은 계속돼요.
context.queryClient는 위젯이 사용하는 클라이언트예요. 가장 가까운FueryProvider의 클라이언트이고,FueryProvider가 없으면Fuery.client예요. 이 클라이언트를 넘기면 MutationState 위젯이 읽는 캐시에 실행이 들어가요.- 클라이언트를 넘기지 않으면 실행은
Fuery.client를 사용해요. Cubit처럼BuildContext가 없는 코드는 이렇게 뮤테이션을 실행해요.
뮤테이션의 모든 실행 보여주기
섹션 제목: “뮤테이션의 모든 실행 보여주기”MutationState 위젯은 어느 화면에서든 뮤테이션의 실행을 보여줘요. 실행이 정의의 mutate, MutationBuilder, useMutation, Cubit, restore(mutations:) 중 어디서 시작했든 정의의 mutationKey로 찾아요. MutationState 위젯은 StatelessWidget에서도 동작해요.
| 위젯 | 빌드에 쓰는 값, 또는 받는 변화 |
|---|---|
MutationStateBuilder |
모든 실행의 MutationState, 오래된 실행부터 |
MutationStateSelector |
그 상태에서 선택한 값. 값이 바뀔 때만 다시 빌드해요. |
MutationStateListener |
사이드 이펙트에 사용하는, 실행마다 일어나는 모든 변화 |
할 일을 추가하는 동안에는 위 버튼을 비활성화해요.
MutationStateSelector( mutation: addTodo, selector: (runs) => runs.any((run) => run.isPending), builder: (context, adding) => FilledButton( onPressed: adding ? null : () => addTodo.mutate('Buy milk', context.queryClient), child: Text(adding ? 'Adding…' : 'Add'), ),)서버로 보내는 중인 제목이에요. 정의의 변수처럼 String 타입이에요.
MutationStateBuilder( mutation: addTodo, builder: (context, runs) => Column( children: [ for (final run in runs) if (run case MutationState(isPending: true, :final variables?)) ListTile(title: Text(variables)), ], ),)조건에 맞는 실행
섹션 제목: “조건에 맞는 실행”Mutation을 받으면 위젯은mutationKey가 정의의 키와 정확히 같은 실행을 정의와 같은 타입으로 보여줘요.- 키와 타입이 같은 다른 정의의 실행도 포함해요.
- 그 키에 타입이 다른 실행이 있으면 빼고,
onUncaughtError로 한 번 전달해요. 정의마다 키를 따로 주세요. mutationKey가 없는 정의는 디버그 빌드에서 assert에 실패해요.MutationFilters를 받으면 위젯은 필터 조건에 맞는 모든 뮤테이션의 실행을 보여줘요.client.mutationCache.findAll처럼 키 접두사,exact키,status,predicate로 찾아요. 이때 상태의 타입은Object?예요.status필터가 없으면 끝난 실행도 조건에 맞아요.
실행 순서와 수명
섹션 제목: “실행 순서와 수명”- 실행은 오래된 것부터 나열돼요. 그래서
runs.lastOrNull이 가장 최근 실행이에요. - 실행은 끝난 뒤에도
gcTime(가비지 컬렉션 시간, 기본값: 5분) 동안 남아요. 그러니 로딩 표시는 실행 개수가 아니라isPending으로 만드세요. - 마운트된
MutationBuilder는 가장 최근 실행을 보여주는 동안 그 실행을 유지해요. client.clear()는 모든 실행을 제거해요.
클라이언트와 비용
섹션 제목: “클라이언트와 비용”- 위젯의 클라이언트에 있는 실행만 포함해요. 위젯의 클라이언트는 가장 가까운
FueryProvider의 클라이언트나Fuery.client예요. - MutationState 위젯은 읽기만 해요. 뮤테이션을 실행하지 않고 정의의 옵션도 적용하지 않아요. 그래서
build에서 정의를 만들어도 비용이 들지 않아요.
뮤테이션이 실패했다고 사용자에게 알리기
섹션 제목: “뮤테이션이 실패했다고 사용자에게 알리기”mutate가 실패하면 에러를 일으키지 않고 실행의 상태에 에러를 담아요. MutationStateListener는 어느 화면에서 시작했든 뮤테이션의 모든 실행의 변화를 받아요. 리스너는 실행마다 새 상태를 받아요.
MutationStateListener( mutation: addTodo, listenWhen: (previous, current) => current.isError, listener: (context, run) => ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Could not add "${run.variables}"')), ), child: const TodoScreen(),)- Fuery는 바뀐 실행마다 리스너를 한 번씩 호출해요. 그래서 실행 두 개가 실패하면 두 번 호출해요.
listenWhen은 그 실행의 이전 상태와 새 상태를 비교해요.- 리스너가 마운트될 때 실행에 이미 있던 상태나, 캐시가 제거한 실행으로는 리스너를 호출하지 않아요.
- 리스너는 복원한 실행과 다른 화면의 실행의 변화도 받아요. 그러니 메시지를 보여줄 곳에 한 번만 마운트하세요.
MutationListener는 자기가 받은 옵저버의 실행에서만 변화를 받아요. 정의를 받으면 자기 옵저버를 만들지만, 그 옵저버로 뮤테이션을 실행하는 코드가 없어요. 그래서 아무 변화도 받지 못해요. 디버그 빌드에서는 경고도 출력해요. MutationListener가 한 번도 실행되지 않아요를 참고하세요.
호출 한 번이 성공한 뒤 처리하기
섹션 제목: “호출 한 번이 성공한 뒤 처리하기”mutateAsync는 실행이 성공하면 데이터를 반환하고, 실패하면 에러를 일으켜요. 저장한 폼을 닫는 것처럼 호출 한 번이 끝난 뒤 할 일이 있으면 mutateAsync를 await로 기다리세요.
FilledButton( onPressed: () async { try { await addTodo.mutateAsync('Buy milk', context.queryClient); } catch (_) { return; // The MutationStateListener above reports the failure. } if (context.mounted) Navigator.pop(context); }, child: const Text('Add'),)await뒤에context.mounted를 확인하세요. 실행이pending상태인 동안 사용자가 화면을 떠날 수 있어요.- 에러를 잡으세요. 어디서도 잡지 않은 에러는 잡히지 않은 에러로 zone에 전달돼요.
위젯이 시작한 실행만 보여주기
섹션 제목: “위젯이 시작한 실행만 보여주기”MutationBuilder는 자기 옵저버를 두고, 자기가 시작한 실행만 보여줘요. 여러 폼에 Save 버튼이 하나씩 있을 때처럼, 위젯의 상태에서 다른 곳에서 시작한 실행을 빼야 하면 MutationBuilder를 사용하세요.
MutationBuilder( mutation: addTodo, builder: (context, state) => FilledButton( onPressed: state.isPending ? null : () => state.mutate('Buy milk'), child: Text(state.isPending ? 'Adding…' : 'Add'), ),)state.mutate('Buy milk')는 이 위젯으로 실행을 시작해요.await state.mutateAsync('Buy milk')는 데이터를 반환하고, 실패하면 에러를 일으켜요.state는 이 위젯이 시작한 가장 최근 실행을 보여줘요.addTodo.mutate나 다른 위젯이 시작한 실행은state에 나타나지 않아요.state.reset()은 상태를idle로 되돌려요.- 위젯은 가장 가까운
FueryProvider의 클라이언트를 사용하고,FueryProvider가 없으면Fuery.client를 사용해요. HookWidget에서는useMutation(addTodo)가 같은 결과를 반환해요. 훅을 참고하세요.
state의 모든 멤버와 필드는 뮤테이션 결과에 있어요.
이 위젯의 호출 한 번에 반응하려면 state.mutate에 MutateOptions를 넘기세요. 그 콜백은 호출이 끝나면 뮤테이션 자체의 콜백 다음에 실행돼요.
state.mutate( 'Buy milk', MutateOptions(onSuccess: (todo, title, _, __) => showAddedSnackBar(todo)),);- 같은 옵저버에서
mutate를 다시 호출하면 콜백이 바뀌어요. 가장 최근 호출의 콜백만 실행돼요. reset()을 호출하면 콜백을 버려요. 위젯이 언마운트될 때도 버려요.- 공유하는 옵저버는 위젯이 구독하든 안 하든 콜백을 실행해요. 콜백에서
BuildContext를 사용하기 전에context.mounted를 확인하세요.
onMutate는 mutationFn보다 먼저 실행돼요. onSuccess, onError, onSettled는 mutationFn 다음에 실행돼요. 콜백의 인수와 순서는 콜백에 있어요.
- 마지막 인수
client는 뮤테이션을 실행하는 클라이언트예요. 정의의mutate에 넘긴 클라이언트, 위젯이FueryProvider에서 받은 클라이언트,observe(client:)에 넘긴 클라이언트 중 하나예요.Fuery.client대신 이client를 사용하세요. 그래야 테스트에서도 콜백이 올바른 캐시를 다뤄요. onMutate,onSuccess,onError,onSettled가Future를 반환하면 그Future가 완료될 때까지 실행은pending상태로 남아요. Fuery는MutateOptions콜백은 기다리지 않아요. 앞의addTodo는onSuccess에서invalidateQueries의Future를 반환해요. 그래서 목록을 다시 가져올 때까지 버튼에 *Adding…*이 보여요.
낙관적 업데이트
섹션 제목: “낙관적 업데이트”낙관적 업데이트는 서버가 응답하기 전에 캐시를 바꿔요. 그래서 화면이 바로 반응해요.
onMutate에서 쿼리를 다시 가져오는 중이면 취소하세요. 그래야 다시 가져온 데이터가 업데이트를 덮어쓰지 않아요.- 이어서
onMutate에서 새 데이터를 쓰고 이전 데이터를 반환하세요. 다른 콜백은 이 값을context로 받아요. onError에서 이전 데이터를 다시 쓰세요.onSettled에서 쿼리를 무효화해 서버에 있는 데이터를 다시 가져오세요.
todosQuery와 todosKey는 쿼리와 그 쿼리의 키예요.
final deleteTodo = Mutation( mutationFn: (int id) => api.deleteTodo(id), onMutate: (id, client) async { // Keep a refetch in flight from overwriting the optimistic update. await client.cancelQueries(queryKey: todosKey); final previous = client.getData(todosQuery); client.updateData( todosQuery, (todos) => todos?.where((todo) => todo.id != id).toList(), ); return previous; }, onError: (error, id, previous, client) { if (previous != null) client.setData(todosQuery, previous); }, onSettled: (_, __, ___, ____, client) { return client.invalidateQueries(queryKey: todosKey); },);변수 없는 뮤테이션
섹션 제목: “변수 없는 뮤테이션”로그아웃처럼 아무것도 받지 않는 뮤테이션에는 NoVariablesMutation을 사용하세요. mutationFn과 콜백에는 변수가 없어요(NoVariablesMutation).
final logoutMutation = NoVariablesMutation( mutationFn: () => api.logout(),);logoutMutation.mutate()로 실행해요. 그래서 버튼에 tear-off를 넘길 수 있어요. onPressed: logoutMutation.mutate처럼요. logoutMutation.mutateAsync()는 데이터를 반환해요.
Fuery.client가 아닌 클라이언트에서는 변수 자리에 먼저null을 넘기세요.logoutMutation.mutate(null, context.queryClient)처럼요.MutationBuilder나useMutation의 결과는 변수 타입이void예요. 그래서state.mutate(null)로 뮤테이션을 실행해요. 호출 한 번의 콜백에는 변수 인수가 그대로 있고, 그 값은null이에요.observe()로 만든 옵저버인NoVariablesMutationObserver는mutate()로 뮤테이션을 실행해요.
로그아웃한 뒤에는 앱이 캐시를 사용하던 화면을 떠난 다음 캐시를 비우세요. 이 순서가 왜 중요한지는 로그아웃할 때 모두 비우기에서 설명해요.
재시도와 실행 순서
섹션 제목: “재시도와 실행 순서”뮤테이션은 retry를 설정했을 때만 재시도해요. 쓰기를 반복해도 항상 안전하지는 않기 때문이에요. RetryPolicy.count(2)는 시도를 두 번 더 허용해요. 첫 재시도는 1초 뒤, 두 번째 재시도는 다시 2초 뒤예요. scope를 공유하는 뮤테이션은 시작한 순서대로 하나씩 실행돼요.
final saveDraft = Mutation( mutationFn: (Draft draft) => api.saveDraft(draft), retry: const RetryPolicy.count(2), scope: const MutationScope('drafts'),);스코프에서 차례를 기다리는 실행은 isPaused가 true예요. 네트워크를 기다리는 실행도 마찬가지예요. 앱을 다시 시작해도 기다리는 실행을 유지하려면 뮤테이션에 persist를 설정하세요(뮤테이션 저장하기).
옵저버 하나 공유하기
섹션 제목: “옵저버 하나 공유하기”정의의 mutate와 MutationState 위젯에는 직접 만든 옵저버가 필요 없어요. Cubit도 정의에서 뮤테이션을 실행해요(Cubit이나 Bloc에서 뮤테이션 실행하기). 여러 위젯이 한 화면의 실행만 따라가야 할 때만 옵저버 하나를 공유하세요.
addTodo.observe()는 MutationObserver를 반환해요. 이 옵저버를 받은 위젯은 모두 옵저버를 그대로 사용해요. 여기서는 이 화면의 폼이 저장하는 동안 앱 바에 진행 표시줄을 보여줘요. MutationStateSelector를 사용하면 다른 화면이 시작한 실행도 보여줘요.
class _AddTodoScreenState extends State<AddTodoScreen> { late final adding = addTodo.observe(client: context.queryClient);
@override void dispose() { adding.reset(); super.dispose(); }
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('New todo'), bottom: PreferredSize( preferredSize: const Size.fromHeight(4), child: MutationSelector( mutation: adding, selector: (state) => state.isPending, builder: (context, saving) => saving ? const LinearProgressIndicator() : const SizedBox(height: 4), ), ), ), body: AddTodoForm(onSubmit: adding.mutate), ); }}화면에서 호출한 뮤테이션이 성공한 뒤 화면을 닫을 때는 공유하는 옵저버가 필요 없어요. mutateAsync를 await로 기다리세요(호출 한 번이 성공한 뒤 처리하기).
- 옵저버는
State필드나 Cubit에서 한 번만 만드세요.build에서observe()를 호출하면 다시 빌드할 때마다idle상태의 새 옵저버를 반환해요. observe()는client:를 넘기지 않으면Fuery.client를 사용해요. 자기 클라이언트가 있는FueryProvider아래에서는 위 코드처럼context.queryClient를 넘기세요.dispose에서reset()을 호출하세요.reset()은 이 화면에 속한 가장 최근mutate호출의 콜백을 버려요. 정의를 받은 위젯은 언마운트될 때 자기 옵저버를 초기 상태로 되돌려요.- 이 옵저버를 받은
MutationListener,MutationSelector,MutationBuilder는 어디서 호출했든 옵저버가 시작한 모든 실행의 변화를 받아요.
예제 앱에서
섹션 제목: “예제 앱에서”- 피드 뮤테이션에는 롤백하는 낙관적 좋아요와, 오프라인일 때 멈추는 댓글이 있어요.
- 피드는 좋아요를 정의에서 실행하고, 실패한 좋아요를 모두
MutationStateListener로 알려요. - 게시물 화면은 정의에서 댓글을 보내고, 보내는 중인 댓글을
MutationStateBuilder로 보여줘요. - 글쓰기 화면은
MutationBuilder로 새 게시물을 올려요. 그 버튼은 자기 실행만 보여줘요.
화면마다 보여주는 기능은 예제의 README에 있어요.