React

Optimistic UI With React Query 정리

Asset Type
File Type
When to use
2022/03/17
Reference
Created by
Created time
2022/03/17 18:18
Last edited time
2022/05/05 12:46

Implement Optimistic UI for insert/update/delete mutations with React Query

리액트 쿼리로 삽입/갱신/삭제 상태 변경을 위한 Optimistic UI(OUI) 구현
If you wonder what Optimistic UI is, I suggest you check out my previous article Better UX with Optimistic UI.
Optimistic UI가 뭔지 궁금하면 이전 글을 참조해라.
I assume that you’re familiar with React Query. If not, you can check my other article Fetch, Cache, and Update Data Effortlessly with React Query. It is really easy to use. I love React Query! Not only you can control every detail of how you fetch, cache, and use the data you get from the server but also you can implement optimistic updates easily with React Query. You can check out the docs on optimistic updates here.
리액트 쿼리는 잘 알 것이다. 만약 아니라면 이 글을 읽어보라.정말 사용하기 편하다...
모든 세부 사항을 제어할 수 있을 뿐만 아니라 optimistic updates를 쉽게 구현할 수 있다.
Generally, an optimistic update comes with a mutation. A mutation is typically used to insert/update/delete data or perform server side-effects. You’re likely going to have something like this:
일반적으로 OUI은 mutation을 수반한다. mutation은 일반적으로 삽입/갱신/삭제하거나 서버의 side-effects를 수행하기 위해 사용한다.
useMutation(yourMutationFunction, { // Before the mutation starts 뮤테이션하기 이전 async onMutate() { /* 1. Stop the queries that may affect this operation */ // 이 연산에 영향을 줄 가능성이 있는 쿼리를 정지 /* 2. Modify cache to reflect this optimistic update */ // 이 optimistic update를 반영하도록 캐시를 수정합니다 /* 3. Return a snapshot so we can rollback in case of failure */ // 장애 발생 시 롤백할 수 있도록 스냅샷을 반환 const context = { ... } return context }, // Mutation throws an error onError(error, newItem, context) { /* Rollback the changes using the snapshot */ // 스냅샷을 사용해서 변경 롤백 }, // After mutation ends successfully onSuccess() { /* Refetch or invalidate related queries */ // 관련 쿼리 리페치 또는 비활성화 } })
TypeScript
복사

Like Button

I showed you how to implement a like button in the previous article. Let’s see how we can achieve this with React Query.
Twitter Like Button
Suppose you have a component that renders the list of tweets using React Query:
좋아요 버튼 구현 방법을 보여줬다. 리액트 쿼리로 실현하는 방법을 보여주겠다.
리액트 쿼리를 사용하여 트윗 목록을 렌더링하는 구성요소가 있다고 가정하자.
import React from 'react' import { useQuery } from 'react-query' import LikeTweet from './LikeTweet' const fetchTweets = async () => { // Fetch tweets from the API } function Tweets() { const { data, isError, isLoading } = useQuery('tweets', fetchTweets); if (isError) { return <p>Failed to load tweets.</p> } if (isLoading) { return <p>Loading tweets...</p> } return ( <ul> {data.map(tweet => ( <li key={tweet.id}> <small>{tweet.username}</small> <p>{tweet.text}</p> <footer> ... <LikeTweet tweetId={tweet.id} isLiked={tweet.is_liked} /> ... </footer> </li> ))} </ul> ) }
TypeScript
복사
Nothing serious in the above codes, we fetch tweets list using useQuery with the name of tweets.
Our main focus is on the LikeTweet component. This is where we’re going to implement the mutation to like a tweet.
위 코드에는 문제가 없다. 트윗의 이름을 가진 useQuery를 사용하여 트윗 목록을 가져온다.
LikeTweet 컴포넌트에 초점을 맞췄다. 여기서 트위터를 좋아하기 위한 상태 변경을 구현한다.
import React from 'react' import { useMutation, useQuery, useQueryClient } from 'react-query' const toggleLikeTweet = async (tweetId) => { // Send a request to API } function LikeTweet({ tweetId, isLiked }) { const queryClient = useQueryClient() const mutation = useMutation(toggleLikeTweet, { onMutate: async (tweetId) => { // 1. Stop the queries that may affect this operation // 이 연산에 영향을 줄 가능성이 있는 쿼리를 정지 await queryClient.cancelQueries('tweets') // Get a snapshot of current data const snapshotOfPreviousTweets = queryClient.getQueryData('tweets') // 2. Modify cache to reflect this optimistic update // 이 optimistic update를 반영하도록 캐시를 수정합니다 queryClient.setQueryData('tweets', oldTweets => oldTweets.map(tweet => { if (tweet.id === tweetId) { return { ...tweet, is_liked: !tweet.is_liked } } return tweet })) // 3. Return a snapshot so we can rollback in case of failure // 장애 발생 시 롤백할 수 있도록 스냅샷을 반환 return { snapshotOfPreviousTweets } }, onError: (error, tweetId, { snapshotOfPreviousTweets }) => { // Rollback the changes using the snapshot // 스냅샷을 사용해서 변경 롤백 queryClient.setQueryData('tweets', snapshotOfPreviousTweets) }, onSuccess() { // Refetch or invalidate related queries // 관련 쿼리 리페치 또는 비활성화 queryClient.invalidateQueries('tweets') } }) const handleClick = async () => { mutation.mutate(tweetId) // 여기서는 프로미스를 둬도 된다면, mutateAsync도. } return ( <button onClick={handleClick}> {isLiked ? <HeartFilledSVG /> : <HeartOutlinedSVG />} </button> ) }
TypeScript
복사
Let’s go step by step to see what happens here exactly. The user clicks the like/dislike button and the onClick callback is triggered. We call the mutate function on the mutation object to fire the mutation. So, what are these callback functions we pass to useMutation as the second parameter? Let’s find out!
여기서 정확히 무슨 일이 벌어지는지 차근차근 살펴본다. 사용자가 [좋아요]/[싫어요]버튼을 클릭하면 onClick 콜백이 트리거된다. mutation object의 mutate 함수를 호출하여 mutation을 발생시킨다.
그럼 두 번째 파라미터로 Mutation을 사용하기 위해 건네주는 콜백 함수는 무엇일까?
onSuccess()에서 queryClient.invalidateQueries로 쿼리를 비활성화시켜버리면 어떻게 되길래 저걸 호출한 걸까? 강제 리프레쉬 역할이다.

onMutate

This is the first function that is called, before the API request. We do the following steps inside onMutate:
Stop any outgoing query to prevent overwriting our optimistic cache update.
Get a snapshot of the current tweets. This will be used to roll back the tweets in case the mutation fails for any reason.
Optimistically update the cache to like the tweet. Here, we update the React Query’s cache to locally like the tweet, before we actually send the API request to like it.
Finally, return the snapshot objects. This will be used to roll back the changes in onError callback.
optimistic cache update를 덮어쓰지 않도록 발신 쿼리를 중지한다.
현재 트윗의 스냅샷을 가져옵니다. 어떤 이유로든 변환에 실패할 경우 트윗을 롤백하기 위해 사용됩니다.
트윗을 좋아요하도록 캐시를 낙관적으로 업데이트합니다. 여기에서는 로컬에서 트윗을 좋아요하기 위해 리액트 쿼리의 캐시를 업데이트한 후 실제로 좋아요하기 위한 API 요청을 보냅니다.
마지막으로 스냅샷 개체를 반환합니다. 이것은 onError 콜백의 변경을 롤백하기 위해 사용됩니다.

onError

This will be called if the API request throws an error. This will tell us that the like tweet request is failed and we need to roll back the changes we made in the cache.
API 요청이 오류를 발생시키면 호출됩니다. 이는 유사한 트윗 요청이 실패했음을 알려주고 캐시에서 변경한 내용을 롤백해야 합니다.

onSuccess

This callback will be called after the API request finishes successfully. We’re going to re-fetch or invalidate the tweets query to get the latest updates from the server.
That’s it! Let’s see another example.
이 콜백은 API 요구가 정상적으로 완료되면 호출됩니다. 서버에서 최신 업데이트를 가져오기 위해 "tweets" 쿼리를 다시 가져오거나 비활성화합니다.

Insert a Todo

I hope you have guessed by now that this example is somehow the same as the above. You have a list of to-dos to render and a text input to allow the user to create new to-dos.
이상과 같은 예라고 생각하셨으면 좋겠습니다. 렌더링할 작업 목록과 사용자가 새 작업을 만들 수 있는 텍스트 입력이 있습니다.
Firstly, we have a list of to-dos. You will use the useQueryhook to load the tasks list:
import React from 'react' import { useQuery } from 'react-query' const fetchTodos = async () => { // Fetch todos from the API } function Todos() { const { data, isError, isLoading } = useQuery('todos', fetchTodos) if (isError) { return <p>Failed to load todos.</p> } if (isLoading) { return <p>Loading todos...</p> } return ( <> <InsertTodo /> <ul> {data.map(todo => ( <li key={todo.id}> {todo.text} </li> ))} </ul> </> ) }
TypeScript
복사
And here’s the InsertTodocomponent:
import React from 'react' import { useMutation, useQueryClient } from 'react-query' const insertTodo = async (todoId) => { // Send a request to API } function InsertTodo() { const [text, setText] = useState('') const queryClient = useQueryClient() const mutation = useMutation(insertTodo, { onMutate: async (newTodo) => { // Stop the queries that may affect this operation // 마찬가지로 쿼리 중지 await queryClient.cancelQueries('todos') // Get a snapshot of current data // 현재 스냅샷을 가져와서 실패시 롤백용으로 갖고 있자 const snapshotOfPreviousTodos = queryClient.getQueryData('todos') // Modify cache to reflect this optimistic update // 낙관적 캐시 업데이트 후에 실제 API 요청을 할 것 queryClient.setQueryData('todos', oldTodos => [ newTodo, ...oldTodos ]) // Return a snapshot so we can rollback in case of failure // 아까 구해놓은 스냅샷을 Error시 롤백용으로 쓰려고 반환 return { snapshotOfPreviousTodos } }, onError: (error, newTodo, { snapshotOfPreviousTodos }) => { // Rollback the changes using the snapshot // onMutate에서 반환한 스냅샷 갖다가 롤백시키기 queryClient.setQueryData('todos', snapshotOfPreviousTodos) }, onSuccess() { // Refetch or invalidate related queries // 서버에서 최신 업데이트를 가져오기 위해 "todos" 쿼리를 다시 가져오거나 비활성화 queryClient.invalidateQueries('todos') }, onSettled: () => { // This will run in the end, no matter of failure or success setText('') // Clear textarea } }) const handleSubmit = (event) => { event.preventDefault() mutation.mutate({ id: '__RANDOM_TEMP_ID__', text }) } return ( <form onSubmit={handleSubmit}> <textarea placeholder="Add a new todo..." onChange={event => setText(event.target.value)} value={text} /> <button type="submit"> Save </button> </form> ) }
TypeScript
복사
As you can see, we’re doing the exact same things we did in the Like Button example. The only difference here is that we are prepending the new item to the list of to-dos.
보다시피 Like Button 예시와 동일한 작업을 수행하고 있다. 여기서 유일한 차이점은 새로운 아이템을 할 일 목록에 추가한다는 것이다.
Also, we’re using a new callback parameter here, onSettled, to clear the text. This is the callback that will be called after the mutation ends, no matter if it fails or succeeds.
또한 여기에서는 새로운 콜백 파라미터 onSettled를 사용하여 텍스트를 클리어하고 있다. 이것은 변환이 실패했는지 성공했는지 여부에 관계없이 변환이 종료된 후에 호출되는 콜백이다.
If you noticed, we’re using a random ID for the new to-do (line 53). This is only a temporary value to help the to-dos list render properly because after the mutation is finished successfully, we start re-fetching the to-dos and this data will be replaced with the actual data from the server.
눈치챘다면, 우리는 새로운 할 일에 랜덤 아이디를 사용하고 있다(53행). mutation이 정상적으로 완료된 후 To-Do를 다시 가져오기 시작하고 이 데이터는 서버의 실제 데이터로 대체되므로 To-Do 목록을 올바르게 렌더링하는 데 도움이 되는 임시 값일 뿐이다.

Okay, but what about deleting an item, like deleting a to-do…?

좋습니다. 그런데 항목을 삭제하는 것은 어떨까요? 예를 들어, todo 삭제...?
I guess you can now easily implement it! You only need to add a button in each to-do and write a mutation for it. The only difference is when you update the cache using the queryClient, you will filter the array to exclude the to-do that is being deleted. That’s it!
Hopefully, it makes sense to you. If you have any questions, don’t hesitate to
이제 쉽게 구현할 수 있을 것! 각 todo에서 버튼을 추가하고 mutation만 작성하면 된다.
유일한 차이점은 queryClient를 사용하여 캐시를 업데이트하면 array를 필터링하여 삭제 중인 todo를 제외한다는 것이다. 다 됐다!
이해가 되시기를 바란다.