쿼리는 unique key
에 연결된 asynchronous source of data
에 대한 declarative dependency
이다.
쿼리는 서버에서 데이터를 가져오기 위해 GET 및 POST 메소드를 포함한 모든 Promise 기반 메소드와 함께 사용할 수 있다.
하지만 메소드가 서버의 데이터를 수정하는 경우엔 Mutations를 추천한다.
컴포넌트 혹은 custom hooks 을 구독하기위해 useQuery
훅을 사용해야한다.
이때 query 에 대해 unique key
가 반드시 필요하다.
그리고 해당 Promise 메소드는 반드시 Resolve data 혹은 Error 를 반환해야한다.
제공하는 unique key
는 애플리케이션 전체에서 쿼리를 새로 고침, 캐싱 및 공유하기 위해 내부적으로 사용된다.
const result = useQuery("todos", fetchTodoList);
그리고 이 result 는 템플릿 작성 및 데이터의 기타 사용을 위해 필요한 쿼리에 대한 모든 정보를 포함하고 있다. 예를들어isLoading
, isError
, isSuccess
, isIdle
, etc...
이외에도 중요한 것은
1. error
쿼리가 isError 상태인 경우, 오류는 error 속성을 통해 사용 가능하다.
2. data
쿼리가 성공 상태인 경우, 데이터는 data 속성을 통해 사용 가능하다.
3. isFetching
어떤 상태에서든, 쿼리가 언제든지 (백그라운드에서 새로 고침을 포함하여) 가져오는 중이면 isFetching은 bool 값이다.
ref
로 래핑된다.즉, 원래같으면
const getUserList = async (payload) => {
isLoading.value = true
try {
const { data } = await memberLogin(payload)
userList.value = data.userList
} catch (error) {
if (error instanceof AxiosError) {
const errorObj = new Error(exceptionHandler(error.response!.status).text)
throw errorObj
}
}
isLoading.value = false
}
이렇게 작성해줘야할 코드가
const { status, data, error, fetchStatus } = useTodosQuery();
이렇게 바뀐다는 것이다. 물론 드라마틱하게 바뀌지 않았다 느낄 수 있지만 vue query 의 장점은 자동화에 있다.
앞서 언급한 stale 개념의 도입으로 상황에 따라 알아서 update 됨으로써 여타 다른 상태관리 로직에 덜 집중해도 된다.
stale-while-revalidate, Background refetches 전략으로 status
, fetchStatus
조합을 통하여 더 명확한 UX 를 제공할 수 있다.
status => succes && fetchStatus => idle 일때 완료
status => succes && fetchStatus => fetching 일때 백그라운드에서 새로고침 중
status => loading && fetchStatus => fetching 일때 데이터 가져오는 중
, 네트워크 연결 끊김
따라서 로직을 작성할 때 아래와 같이 작성해야한다.
<span v-else-if="isFetching && !isLoading">Fetching...</span>
mutation 은 queries 와는 다르게 create
, update
, delete
http method 와 함께 많이 쓰인다.
<script setup>
import { useMutation } from "vue-query";
function useAddTodoMutation() {
return useMutation((newTodo) => axios.post("/todos", newTodo));
}
const { isLoading, isError, error, isSuccess, mutate, reset } = useAddTodoMutation();
function addTodo() {
mutate({ id: new Date(), title: 'Do Laundry' });
}
</script>
<template>
<span v-if="isLoading">Adding todo...</span>
<span v-else-if="isError">An error occurred: {{ error.message }}</span>
<span v-else-if="isSuccess">Todo added!</span>
<button @click="addTodo">Create Todo</button>
</template>
공식문서에는 위와 같이 적혀있다. 하지만 만약 1개의 컴포넌트에서 여러개의 mutation 을 실행할 땐 구조분해를 사용할 수 없다.
isIdle
or status === 'idle'
- mutation 이 완료되었거나 업데이트 된 상태이다.
isLoading
or status === 'loading'
- mutation 이 진행중이다.
isError
or status === 'error'
- mutation 이 error 가 되었다.
isSuccess
or status === 'success'
- mutation 이 성공적으로 완료되었고 결과 data 를 볼 수 있다.
그리고 queries 와 다르게 mutate 와 reset 이 존재하는 것을 알 수 있는데 mutate 는 마치 fetch api 처럼 사용하는 것이고 reset 은 error 가 발생했을 시 다시 한 번 시도할 수 있도록 도와주는 메서드이다.
useMutation 훅은 각 라이프 사이클 별로 신속하게 side-effect 를 처리할 수 있도록 도와준다. 또 이 라이프 사이클을 잘 이용한다면 무효화 하거나 혹은 다시 시도하거나 혹은 Optimistic updates 를 사용하는데도 도움이 된다.
Optimistic updates 란?
Optimistic updates는 사용자 인터페이스(UI)에서 데이터를 수정할 때 서버 응답을 기다리지 않고 즉시 UI를 업데이트하는 기술이다.
이 방법은 사용자 경험을 향상시키기 위해 사용되며, 요청이 성공할 것이라는 "낙관적" 가정에 기반한다. 만약 서버 요청이 실패하면, UI는 원래 상태로 롤백된다.
예를 들어, 사용자가 웹 애플리케이션에서 항목을 삭제하는 작업을 수행할 때, 낙관적 업데이트를 사용하면 해당 항목이 즉시 UI에서 사라진다. 이는 사용자에게 빠른 피드백을 제공하며, 실제 서버에서 항목이 삭제되는 동안 사용자는 다른 작업을 계속할 수 있다. 만약 항목 삭제 요청이 서버에서 실패한다면, UI는 삭제되기 전 상태로 항목을 다시 보여주게 된다.
Optimistic updates는 특히 네트워크 지연이나 서버 처리 시간으로 인해 응답이 느릴 때 사용자 경험을 크게 개선할 수 있다. 이 기술을 통해 애플리케이션은 더욱 반응적이고, 생동감 있게 느껴질 수 있다.
function useAddTodoMutation() {
return useMutation((newTodo) => axios.post("/todos", newTodo), {
onSuccess: (data, variables, context) => {
// I will fire first
},
onError: (error, variables, context) => {
// I will fire first
},
onSettled: (data, error, variables, context) => {
// I will fire first
},
});
}
const { mutate } = useAddTodoMutation();
mutate(todo, {
onSuccess: async (data, variables, context) => {
console.log("I'm first!");
// I will fire second!
},
onError: async (error, variables, context) => {
console.log(`rolling back optimistic update with id ${context.id}`);
// I will fire second!
},
onSettled: async (data, error, variables, context) => {
console.log("I'm second!");
// I will fire second!
},
});
mutate를 호출할 때 useMutation에 정의된 콜백 외에 추가적인 콜백을 실행하고 싶을 수도 있다.
이때 선언한 Fn 에서 구조분해로 mutate 를 또 빼와서 걔를 생명주기에 따라 또 콜백을 작성할 수 있다.
하지만 컴포넌트가 mutation이 완료되기 전에 마운트 해제되면 이 추가 콜백이 실행되지 않는다.
mutateAsync는 Promise 를 바탕으로 만들어졌다. 따라서 resolve, reject 를 만드는 것 처럼 비슷하게 작성하면 된다.
const { mutateAsync } = useAddTodoMutation();
function myAction() {
try {
const todo = await mutateAsync(todo);
console.log(todo);
} catch (error) {
console.error(error);
} finally {
console.log("done");
}
}
그러면 궁금할 것이다. 아니 mutations 에 onError 가 있고 onSucces 가 있고 또 onSettled 가 있는데 굳이 mutateAsync 를 사용하나?
useMutation의 onError와 onSuccess 콜백을 사용하는 것과 mutateAsync를 사용하여 try-catch-finally 패턴을 사용하는 것 사이에는 몇 가지 중요한 차이점이 있다.
useMutation
의 콜백(onError
, onSuccess
, onSettled
)은 뮤테이션의 생명주기에 따라 자동으로 호출되는 함수이다.
이 방식은 뮤테이션의 결과(성공, 실패, 결정)에 따라 실행될 로직을 선언적으로 설정할 수 있게 해준다.
이러한 콜백은 뮤테이션 로직과 관련된 사이드 이펙트를 처리하는 데 유용하며, 전역 상태 관리나 캐시 무효화 등의 로직을 구현하는데 적합하다.
다만 컴포넌트가 언마운트되기 전에 mutation이 완료되지 않으면 이 콜백은 실행되지 않는다.
mutateAsync
를 사용하면 JavaScript의 비동기 패턴인 async/await와 함께 try-catch-finally를 사용하여 에러 핸들링과 후처리를 할 수 있다.
이 방식은 조건부 로직을 처리하거나, 특정 컴포넌트에 국한된 side-effect를 실행할 때 유용하다.
try 블록에서는 mutateAsync
를 호출하여 뮤테이션을 실행하고, 성공 시 결과를 처리할 수 있다.
catch 블록에서는 에러를 핸들링하며,
finally 블록에서는 뮤테이션이 성공하든 실패하든 항상 실행되는 로직을 구현할 수 있다.
이 방식을 사용하여 더 세밀하게 에러 처리를 할 수 있고, 특정 컴포넌트의 상태 업데이트 같은 세밀한 제어가 필요한 경우에 적합하다.
요약하자면,
useMutation
의 콜백을 사용하는 방식은 mutation과 관련된 side-effect를 전역적으로 관리하기에 적합한 반면,
mutateAsync
와try-catch-finally
패턴을 사용하는 방식은 더 세밀한 에러 핸들링이나 조건부 로직, 컴포넌트 특화 로직을 구현할 때 유용하다.
const queryClient = new QueryClient();
// Define the "addTodo" mutation
queryClient.setMutationDefaults("addTodo", {
mutationFn: addTodo,
onMutate: async (variables) => {
// Cancel current queries for the todos list
await queryClient.cancelQueries("todos");
// Create optimistic todo
const optimisticTodo = { id: uuid(), title: variables.title };
// Add optimistic todo to todos list
queryClient.setQueryData("todos", (old) => [...old, optimisticTodo]);
// Return context with the optimistic todo
return { optimisticTodo };
},
onSuccess: (result, variables, context) => {
// Replace optimistic todo in the todos list with the result
queryClient.setQueryData("todos", (old) =>
old.map((todo) => (todo.id === context.optimisticTodo.id ? result : todo))
);
},
onError: (error, variables, context) => {
// Remove optimistic todo from the todos list
queryClient.setQueryData("todos", (old) =>
old.filter((todo) => todo.id !== context.optimisticTodo.id)
);
},
retry: 3,
});
// Start mutation in some component:
const mutation = useMutation("addTodo");
mutation.mutate({ title: "title" });
// If the mutation has been paused because the device is for example offline,
// Then the paused mutation can be dehydrated when the application quits:
const state = dehydrate(queryClient);
// The mutation can then be hydrated again when the application is started:
hydrate(queryClient, state);
// Resume the paused mutations:
queryClient.resumePausedMutations();
코드를 대충 분석해보면 jpa 의 영속성 context 처럼 메모리에 state 를 업데이트를 해두었다가 onSuccess 를 하면 그때 persist 를 날리는 것이다.
조금 자세히 설명하면 만약 장치가 오프라인 상태인 등의 이유로 변이가 일시 중단되었다면,
애플리케이션이 종료될 때 dehydrate
함수를 사용하여 변이를 저장하였다가,
애플리케이션이 다시 시작될 때 hydrate
함수로 저장된 상태를 복원하고,
resumePausedMutations
메서드로 일시 중단된 변이를 재개할 수 있다.
이는 Optimistic updates 를 사용하여 사용자에게 빠른 피드백을 제공하며, Persist mutations와 hydrate는 네트워크 상태가 불안정하거나 애플리케이션이 예기치 않게 종료되는 경우에도 데이터 일관성을 유지할 수 있게 한다.
const mutation = useMutation(addTodo, {
retry: 3,
});
물론 default 는 재시도하지는 않는다. 하지만 옵션을 넣어서 재시도를 시도할 순 있다.