TkDodo / blog-comments

6 stars 1 forks source link

blog/mastering-mutations-in-react-query #31

Closed utterances-bot closed 2 years ago

utterances-bot commented 2 years ago

Mastering Mutations in React Query | TkDodo's blog

Learn all about the concept of performing side effects on the server with React Query.

https://tkdodo.eu/blog/mastering-mutations-in-react-query?utterances=22c272992acbd295c16b127dAie0B3x1NqhQorEKRGvFH71aM5MGXVEjl9WD5QZk2FjzX1Df%2FkFTH3OXs%2BinSDdYvbJn6fzf77VJgjGGyG9yk2I3qyKrT3CxpNink9ZEvoWXCAFbJKwKTpAZCv4%3D

bustamantedev commented 2 years ago

Another great post! You're on πŸ”₯, congrats!

I have a question about some questions about this topic:

TkDodo commented 2 years ago

No, I don’t think it’s a bad practice. Personally, I try to keep the mutation as close to where it’s needed (e.g. the button that performs it) and I usually don’t need the result anywhere else, because the mutation influences associated queries, and I use those everywhere :)

JaeYeopHan commented 2 years ago

thanks for awesome post! i have a question @TkDodo,

i prefer use mutateAsync with unhandled promise rejection for handling unknown, unexpected error

  1. intentionally throw error
  2. capturing error in unhandled promise rejection
  3. show toast message and capture exception stacktrace with sentry

so, my question is...

  1. How about this error handling practice?
  2. How to handle unexpceted error handling with mutate?
TkDodo commented 2 years ago

@JaeYeopHan For global error handling, I would set an onError event handler on the MutationCache. It will be executed once for every error that occurs for any mutation. I'm describing this here: https://tkdodo.eu/blog/react-query-error-handling#the-global-callbacks

JaeYeopHan commented 2 years ago

@TkDodo oh thanks! :)

onError event handler on the MutationCache

that's the part I missed. may i ask you what is the difference between unhandled promise rejection handler and MutationCache onError handler?

TkDodo commented 2 years ago

@JaeYeopHan unhandled promise rejections occur when the browser sees a failed Promise without a .catch anywhere. If you use .mutate, react-query will catch it for you so that can't happen. The global onError callbacks are just additional functions that are invoked when an error occurs.

arthurdenner commented 2 years ago

Awesome post, thanks for sharing! Returning a Promise from onSuccess has solved some UI details in a current project I'm working on :tada:

JanStevens commented 2 years ago

@TKDoDo Thanks for the awesome article, I guess I have been doing mutations all wrong! A common pattern I use is having multiple custom hooks that internally call useMutation and handle the cache invalidation / optimistic updates. Typically a user goes through a multi step form and we need to update / create multiple resources.

I always used mutateAsync so I could await, get the result and then call the next mutation. How would you solve a typical case like that?

TkDodo commented 2 years ago

I always used mutateAsync so I could await, get the result and then call the next mutation. How would you solve a typical case like that?

Dependent mutations can basically be solved in 3 different ways. It's a pity I didn't get to include that in the article, I kinda forgot about it πŸ˜… :

1) make multiple calls inside the mutateFn:

const mutate = useMutation((data) =>
  axios.post('/something', { data })
    .then((somethingResult) =>
        axios.put('/somethingElse', { somethingResult } )
  )
)
<button onClick={() => mutate('data') />

advantage would be one mutation with one loading state. disadvantage would be you can't easily trigger them separately if you'd need to.

2) with mutateAsync:

const mutate1 = useMutation((data) => axios.post('/something', { data }))
const mutate2 = useMutation(somethingResult) => axios.put('/somethingElse', { somethingResult })

<button onClick={async () => {
  try {
    const somethingResult = await mutate1.muteateAsync('data')
    const result = await mutate2.mutateAsync(somethingResult)
  } catch {}
}}/>

a bit boilerplate-y and you'd need to combine loading states very likely

3) like 2, but with mutate and callbacks:

const mutate1 = useMutation((data) => axios.post('/something', { data }))
const mutate2 = useMutation(somethingResult) => axios.put('/somethingElse', { somethingResult })

<button onClick={() => {
    mutate1.mutate('data', {
      onSuccess: mutate2.mutate
    })
}}/>

separated, but still quite concise. will get messy with more than 2 mutations, and the end-result would only be available in the onSuccess callback of the second mutation if you need it

JanStevens commented 2 years ago

Hi, thanks I figured it will be one of those 3, still torn on what should be used best. ATM we use option 2 (without the try / catch woopsie), but we also so a couple of issues with toasters flying in too early and page redirects to newly created resources not always working (cache is being updated but page is already redirecting)

The last option also seems a bit messy but I understand the reasoning. What I'm thinking now is having 3 independent useMutation hook wrappers and then wrap those in a component specific hook also using useMutation, not sure how that will pan out but going to try that. The benefit would be that I still can call the hooks independently but I can also call them in one go.

Thanks for the reply!

hedaukartik commented 2 years ago

how can I mock useIsMutating hook with msw or jest for fetching state of particular mutationKey?

TkDodo commented 2 years ago

@hedaukartik if you use msw, you'd just mock the network request for the given mutation and useIsMutating would just return the correct value, because the request is "in-flight" as far as react-query is concerned. That's the beauty of mocking on that level :)

hedaukartik commented 2 years ago

@TkDodo my use-case was to show loader in different component and not in the component where my useMutation is called. useIsMutating helped me in same. While testing if loader is shown on different component..in my RTL test case of the component..i triggered useMutation wrapper hook using renderHook provided by code>@testing-library/react-hooks</code and with waitFor I was able to see loader in test as well.

In the docs, const isMutatingPosts = useIsMutating(['posts']) this is working for me on any mutation happening. Even if I add 'postsssss' or 'pooosts', it goes in fetching state. This worked for me- const isMutatingPosts = useIsMutating({ mutationKey: 'posts'}) if mutationKey is 'posts' in useMutation.

PS. MSW is smooth with react-query. Thanks for your amazing articles out there.

vishnupeas commented 2 years ago

Beautiful article, enjoyed all the way through including comments.

RSchneider94 commented 2 years ago

Hey, many thanks for your awesome articles! They really helped me a lot to learn react-query more deeply! Unfortunately I'm kinda stuck here with a mutation and I can't figure out how to make this work, if some of you guys had already this scenario and could give some hints it would help me a lot.

So I have built my mutation:

export const useAddPeer = (
  portfolioId: PortfolioId,
): UseMutationResult<PortfolioWithPeers, unknown, PeerRequestBody, unknown> => {
  const queryClient = useQueryClient();

  return useMutation(
    (newPeer: PeerRequestBody) =>
      handleRequest<PortfolioResponse, AddPeersIntoPortfolioRequestBody>({
        url: `${API_PATH}/${portfolioId}/peers`,
        method: 'PUT',
        payload: {
          peers: [newPeer],
        },
      }),
    {
      onSuccess: () => queryClient.invalidateQueries('portfolio-overview'),
    },
  );
};

And here is how I call it:

const addPeer = useAddPeer(activePortfolio.id);

const handleAddPeer = (newPeer: PeerRequestBody) => () => addPeer.mutate(newPeer);

<Box
  onClick={handleAddPeer({
    tickerId: ticker.id,
    isFavorite: false,
  })}
> ... </Box>

and my PUT request works fine, but my mutation never happens (and I went into onSuccess with the debugger) but as far as I saw, I can just see one query key there, but not the one I'm trying to mutate 'portfolio-overview'.

And here is how my portfolio-overview query looks:

export const usePortfolioOverview = (
  portfolioId: PortfolioId,
): UseQueryResult<PortfolioOverviewResponse, Error> =>
  useQuery<PortfolioOverviewResponse, Error>(
    ['portfolio-overview', portfolioId],
    () =>
      handleRequest({
        url: `${API_PATH}/${portfolioId}/overview`,
        method: 'GET',
      }),
    {
      enabled: !!portfolioId,
    },
  );

basically the logic is -> I'm inside another component which I search for the "peers" and after clicking on it, it's added to the portfolio, which then maps all peers and display all of them in a list, and that's what I'm trying here, to mutate this list after adding a new peer to it.

TkDodo commented 2 years ago

@RSchneider94 I don't see anything wrong with that code on first sight. If you can reproduce your issue in codesandbox, it would be easier to analyze :)

RSchneider94 commented 2 years ago

@RSchneider94 I don't see anything wrong with that code on first sight. If you can reproduce your issue in codesandbox, it would be easier to analyze :)

hey @TkDodo ! thanks for your reply! sure, I have created quickly here a Sandbox that the pseudocode has the logic almost the same as mine (of course simpler): https://codesandbox.io/s/lucid-architecture-pny2l

thanks!

TkDodo commented 2 years ago

@RSchneider94 I've answered on the react-query discussion: https://github.com/tannerlinsley/react-query/discussions/2820

seung-00 commented 2 years ago

All articles were big help to me. thank you πŸ₯Ί If you don't mind, can I translate the writings into my native, Korean, and post them on my blog? (of course, including the source)

sushilbansal commented 2 years ago

mutation retries (if the device is offline): 1) will it work if i just mention retries as some number like 3

const mutation = useMutation(addTodo, {
   retry: 3,
 })

2) or i will have to persist the mutation as specified in the docs:

 // 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)

3) in the above case (2) do i have to store state in the LOCAL storage and then hydrate it when application is connected again?

 // The mutation can then be hydrated again when the application is started:
 hydrate(queryClient, state)

// Resume the paused mutations:
 queryClient.resumePausedMutations()

4) I am using persistQueryClient with createAsyncStoragePersistor Do i need to do any of the above if i am using this setup? If yes/no then how query retry will work?

TkDodo commented 2 years ago

@seung-00 Definitely, I'd appreciate translations. Please reach out once you have them, I will link them at the top of the blog. Ideally, I'll do something similar to what Kent C. Dodds has: https://github.com/kentcdodds/kentcdodds.com/blob/main/CONTRIBUTING.md#translation-contributions

TkDodo commented 2 years ago

@sushilbansal mutation retries is probably worth a blog post on its own, and I haven't really played around with it much. From what I know right now, I'd say:

  1. Yes, that should retry your mutations. Retries are generally paused when you are offline and continued when you are back online, so there is nothing more you need to do if that's all you want.
  2. This is an addition to 1) for when you have started a mutation, get offline and the user quits the application (e.g. by closing the tab or refreshing the page). In that case, the mutation would be "lost". To prevent that, you can persist it somewhere with dehydration / hydration and continue it later. 3 and 4: if you are already persisting with one of the experimental persistor plugins, mutations should already be persisted. To resume paused mutations when you hydrate your app, I think all you'd need to do is call: queryClient.resumePausedMutations()
nrbernard commented 2 years ago

Thanks for this great article, Dominik! I understand mutations are generally for updating data on the server, but what about the times when you need to fetch data imperatively and maintaining a cache for it is unnecessary?

For example, when a user clicks a button to upload a file, I need to check whether the file has already been uploaded to the server. Right now I'm just awaiting the response from mutateAsyc in the click event callback and it works fine. Just curious whether you think that's an anti-pattern because I'm just reading data from the server, not changing it. I know I could just make the request without react-query, but I'd like to keep the option to easily configure retries, a loading state, etc.

TkDodo commented 2 years ago

@nrbernard I think this should be fine. One disadvantage is that you still need to join loading / error states together, because it sounds like you have two mutations: one to check if the file has already been uploaded, and then one to actually do the file upload.

Another solution would be to just make two requests inside the one file upload mutation. You can chain promises in there, too, and you will only have one loading / error state. Unless you need individual retries, this should work (pseudo code):

useMutation(async (file) => {
  const exists = await checkIfFileExists(file)
  return exists ? file : uploadFile(file)
})
bartzy commented 2 years ago

Thanks for the great post!

Can React Query help optimistic updates with the following traits?

  1. When the update is still not confirmed (i.e. in the client only, mutation hasn't returned), the new item (let's say it's a new comment in a list) will be displayed a bit differently than "stable"/server-based comments (like Facebook is doing for some products).
  2. In the rare event that the update fails, the new comment in the UI will be shown as unsuccessful/deleted (perhaps with a message for the user about the failure with an option to retry).

It gets into the realm of client state instead of server state, and I think one can claim that this is true for all optimistic updates (it's client state and not server state, and hence its place is not in a server-state library like React Query).

Thanks again!

TkDodo commented 2 years ago

@bartzy

ad 1): definitely. you can append the item to your list in the queryCache, and somehow mark it as temporary (a negative id or in some other form) and then render it e.g. greyed out or so in the ui.

ad 2): when an optimistic update fails, the usual approach is to immediately rollback to the previous state and then re-fetch to get the real server state. But you could also keep the entry in the list and let the user retry it, sure. You'd need to mark it as failed in the onError callback.

incognos commented 2 years ago

Hi, thanks for the great library... I am having tiny bit of oddness where on a mutation the invalidation is being fired before the api call to the server. In other words, it is querying the server prior to making the changes on the server...

  const updateOrder = useMutation<
    ItemType,
    Error,
    { newOrder: string[]; categoryId: string }
  >(
    ({ newOrder, categoryId }) =>
      updateMenuOrder(projectId, thingId, categoryId, newOrder),
    {
      onSuccess: () => {
        queryClient.invalidateQueries(['thing', { id: thingId }]);
      },
    },
  );

the mutation is being used in conjunction with react-beautiful-dnd and triggered on drag end to update the sort order:

  const onDragEnd = result => {
    const { destination, source, draggableId } = result;
    setDraggingFromCategoryId('');
    if (!destination) {
      return;
    }
    if (
      destination.droppableId !== source.droppableId ||
      destination.index === source.index
    ) {
      return;
    }
    const newOrder = Array.from(
      thing[source.droppableId].items,
    );
    newOrder.splice(source.index, 1);
    newOrder.splice(destination.index, 0, draggableId.split('|')[1]);
    updateOrder.mutate({
      newOrder: newOrder,
      categoryId: source.droppableId,
    });
    return;
  };

I cannot seem to figure out why

incognos commented 2 years ago

tried setQueryData instead of invalidating and still no joy... I see it making the call tot there server and returning the correct data, but for some reason it is not updating. even tried to do an optimistic update using the onMutate... this is very odd behavior

incognos commented 2 years ago

ok, solved the issue with onMutate to do an optimistic update, still odd that the invalidate was being fired before the server update

TkDodo commented 2 years ago

@incognos best explanation for me would be that the server returns the response before having actually finished the mutation.

jennifertakagi commented 2 years ago

Hey, thanks a lot for this amazing article... I have a question, can you have some example to unit tests using the setQueryData() inside the mudation to update the cached data? I am really struggling with that πŸ™

NehalDamania commented 2 years ago

Very nice article and an excellent library In the example:

const useUpdateTodo = () =>
  useMutation(updateTodo, {
    // βœ… always invalidate the todo list
    onSuccess: () => {
      queryClient.invalidateQueries(['todos', 'list'])
    },
  })

es-lint raises this error https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/no-floating-promises.md Is it a good practise to ignore the above rule as I have to add return and wait for invalidate to complete :(. Also when we have multiple queries to be invalidated I need to wrap them in Promise.all and return. Does this look ok. Can it be improved

const useUpdateTodo = () =>
  useMutation(updateTodo, {
    // βœ… always invalidate the todo list
    onSuccess: () => {
      return Promise.all([
        queryClient.invalidateQueries(['todos', 'list']),
        queryClient.invalidateQueries(['reminders'])
        ]);
    },
  })

do we see any problem in the above pattern. Bottom line, it seems, we cannot use the below pattern without disabling eslint no-floating-promises rule.

 // πŸš€ fire and forget - will not wait
  onSuccess: () => {
    queryClient.invalidateQueries(['posts', id, 'comments'])
  }
TkDodo commented 2 years ago

@NehalDamania as outlined, react-query will behave differently if you return the Promise, as it will await it. So your mutation will stay in loading state "longer". That might be on purpose, but it might be.

I like the eslint rule because it makes you aware that you are ignoring a floating promise, which could be a mistake, or it could be on purpose. To make it explicit that this is intended, use the void operator:

// πŸš€ fire and forget - will not wait
onSuccess: () => {
  void queryClient.invalidateQueries(['posts', id, 'comments'])
}
TkDodo commented 2 years ago

@jennifertakagi I would guess your struggles come from mocking your custom useMutation hook, in which case the callbacks won't be called. If you stick to mocking the network layer as advised in Testing React Query, the callbacks would be executed just fine (like in the application).

bustamantedev commented 2 years ago

Do you have any suggestions where you need to fire off a dynamic amount of mutate calls and would like to track the isLoading state of the group? Generally we would use a Promise.all().

Does React-Query have anything out of the box for this?

My scenario is for a multiple file upload screen.

nathanhannig commented 2 years ago

Do you have any suggestions where you need to fire off a dynamic amount of mutate calls and would like to track the isLoading state of the group? Generally we would use a Promise.all().

Does React-Query have anything out of the box for this?

My scenario is for a multiple file upload screen.

This was my question posted from your site; I do not know how it linked me with a different user. Appears to be some issue with ulterances extension.

I would also like to add that I want to perform an action once the mutations have all finished.

TkDodo commented 2 years ago

@nathanhannig There was an issue with utterances, which was fixed some days ago. Maybe the script is still cached, I'll try to force a refresh with a query param. sorry for that.

As for the question: We currently do that manually, and track the amount of outgoing requests with a ref, then do something when the counter hits zero. It's not out of the box, but the case is not that common. Something like:

export const useTrackParallelMutations = () => {
    const mutationNumber = React.useRef(0)

    return {
        startOne: () => {
            mutationNumber.current += 1
        },
        endOne: () => {
            if (mutationNumber.current > 0) {
                mutationNumber.current -= 1
            }
        },
        allEnded: mutationNumber.current === 0,
    }
}

You can call startOne in onMutate, endOne in onSettled and then check for allEnded.

Alternatively, you can map over your mutations and create a custom component. That can then have the useMutation call and fire in an effect. If you give it a mutationKey, you can useIsMutating to track the amount of outgoing requests. Also not great, but again, multiple concurrent dynamic mutations is not a case that comes up a lot. In most cases, the apis would support one request to update multiple things :)

For the dynamic loading state of the group, useIsMutating + a mutationKey is definitely the way to go.

ekankr2 commented 2 years ago

Thanks for the great article! I have a question. If I want to pass in parameters when I am using useMutation as a custom hook, how can I pass in parameters?

ex)

export const useSetResearch = () => {
                                                       // api call
    return useMutation(({token, data}: ParamTypes) => setResearch(token, data),
        {
            onSuccess: () => {
                console.log('success')
            },
            onError: () => {
                console.log('fail')
            }
        }
    )
}
// on the component
const {mutate} = useSetResearch()

const submitHandler = () => {
mutate({token: userToken, data: someData},
            {
                onSuccess: () => {
                    console.log('success')
                }
            }
        )
}

am I using it right?

TkDodo commented 2 years ago

@ekankr2 you can pass in parameters either to the custom hook itself, or to the mutate function. I like to mix and match the two depending on use-case:

for example, when updating user info, I often bind the mutation itself to the id, but the actual data comes from the form input, so I pass it to mutate:

const updateUser = ({ id, name }) => axios.post(...)
const useUpdateUser = (id) => useMutation(name => updateUser({ id, name }))

function UserComponent({ userId }) {
  const userMutation = useUpdateUser(userId)

  <MyForm onSubmit={(name) => userMutation.mutate(name)} />
}
bustamantedev commented 2 years ago

Hello Dominik, is there a way from the mutation configuration to not trigger a mutation while there's another one in progress already.

Take this as example, I don't see way of handling this in the from the mutation definition itself and I am repeating logic in a lot of components that are consuming that mutation.

Expected

const useUpdate = () => useMutation(postData)

function MyComponent() {
   const { mutate: update } = useUpdate()

   return <button onClick={update}>Update Data</button>
}

Actual

const useUpdate = () => useMutation(postData)

function MyComponent() {
   const { mutate: update, isLoading } = useUpdate()

  const handleUpdate = () => {
     if (isLoading) { 
         return 
    }
    update()
  }

   return <button onClick={handleUpdate}>Update Data</button>
}
TkDodo commented 2 years ago

@bustamantedev no, I don't think there is a better way. If multiple mutations are involved, useIsMutating can help. But also, it's usually better to disable the button while the mutation is in loading state. That needs to be done manually anyways, and it's arguably the better ux over a button that can be clicked multiple times, but doesn't do anything when clicked the second time. We often disable buttons + add a loading spinner to them, and I've seen other products do this as well.

yehdori commented 2 years ago

Hi :) I am really new to React Query and I have a question.

I want to fetch an GET method api that validates user's phone number when they click the button. The action should only happen on clicks. In my opinion, even though it is a GET method, it would be more appropriate to use a mutation since it should only happen on click. However, I'm not sure because I couldn't find a definitive answer to this. Could you give me some advice to this?

Thanks 😊

TkDodo commented 2 years ago

@yehdori if you don't need to cache the state / share it between multiple components, and you don't need automatic fetching / smart refetching in any way, a mutation is fine. It basically just handles isError / isLoading states for you :)