Skip to content

Instantly share code, notes, and snippets.

@gragland
Last active March 28, 2025 17:18
Show Gist options
  • Save gragland/30a8282714bc6f4f0f6024fee7e9492f to your computer and use it in GitHub Desktop.
Save gragland/30a8282714bc6f4f0f6024fee7e9492f to your computer and use it in GitHub Desktop.

Revisions

  1. gragland revised this gist Sep 24, 2023. 1 changed file with 2 additions and 1 deletion.
    3 changes: 2 additions & 1 deletion use-optimistic-mutation-example.ts
    Original file line number Diff line number Diff line change
    @@ -46,7 +46,8 @@ function Component({items}: {items:Items}) {
    return currentData?.filter((item) => item.itemId !== variables.itemId)
    },
    },
    // Update some local state and rollback on error
    // Update some local state by specifying `getData` and `setData`
    // Useful to handle with this hook so it gets rolled back on error
    {
    getData: () => history,
    setData: (data) => setHistory(data),
  2. gragland revised this gist Sep 24, 2023. 1 changed file with 1 addition and 2 deletions.
    3 changes: 1 addition & 2 deletions use-optimistic-mutation-example.ts
    Original file line number Diff line number Diff line change
    @@ -46,8 +46,7 @@ function Component({items}: {items:Items}) {
    return currentData?.filter((item) => item.itemId !== variables.itemId)
    },
    },
    // Update some local state
    // We can update non React Query data sources by defining `getData` and `setData`
    // Update some local state and rollback on error
    {
    getData: () => history,
    setData: (data) => setHistory(data),
  3. gragland revised this gist Sep 24, 2023. No changes.
  4. gragland renamed this gist Sep 24, 2023. 1 changed file with 0 additions and 0 deletions.
    File renamed without changes.
  5. gragland created this gist Sep 24, 2023.
    69 changes: 69 additions & 0 deletions example.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,69 @@
    import axios from 'axios'
    import { useOptimisticMutation } from "./useOptimisticMutation.ts"

    type Response = boolean
    type Error = unknown
    type MutationVariables = {itemId: string}
    type Items = {id: string; name: string}[]
    type Likes = {itemId: string}[]
    type History = {type: string}[]

    function Component({items}: {items:Items}) {
    // Some local state for our example
    const [history, setHistory] = useState()

    // Mutation to delete an item and optimistically update data in three locations
    const {mutate: deleteItem} = useOptimisticMutation<
    Response,
    Error,
    MutationVariables,
    // Data types for our optimistic handlers
    [
    Items | undefined,
    Likes | undefined,
    History
    ]
    >({
    mutationFn: async (variables) => {
    return axios.post('/api/items/add', variables).then((res) => res.data)
    },
    // This is where the magic happens
    optimistic: (variables) => {
    return [
    // Remove from items
    {
    // The React Query key to find the cached data
    queryKey: ['items'],
    // Function to modify the cached data
    updater: (currentData) => {
    return currentData?.filter((item) => item.id !== variables.itemId)
    },
    },
    // Remove from likes
    {
    queryKey: ['likes'],
    updater: (currentData) => {
    return currentData?.filter((item) => item.itemId !== variables.itemId)
    },
    },
    // Update some local state
    // We can update non React Query data sources by defining `getData` and `setData`
    {
    getData: () => history,
    setData: (data) => setHistory(data),
    updater: (currentData) => {
    return [...(currentData || []), {type: 'delete'}]
    },
    },
    ]
    },
    })

    return (
    <div>
    {items.map(item => (
    <Item item={item} onDelete={() => deleteItem({itemId: item.id}) } />
    )}
    </div>
    )
    }
    114 changes: 114 additions & 0 deletions useOptimisticMutation.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,114 @@
    import {useMutation, useQueryClient, QueryKey, MutationFunction} from '@tanstack/react-query'

    type MutationContext = {
    results: {
    rollback: () => void
    invalidate?: () => void
    didCancelFetch?: boolean
    }[]
    }

    type HandlerReactQuery<TOptimisticData> = {
    queryKey: QueryKey
    updater: (data: TOptimisticData) => TOptimisticData | undefined
    }

    type Handler<TOptimisticData> = {
    getData: () => TOptimisticData
    setData: (data: TOptimisticData) => void
    updater: (data: TOptimisticData) => TOptimisticData | undefined
    }

    type OptimisticFunction<TOptimisticDataArray, TVariables> = (variables: TVariables) => {
    [K in keyof TOptimisticDataArray]:
    | HandlerReactQuery<TOptimisticDataArray[K]>
    | Handler<TOptimisticDataArray[K]>
    }

    type OptimisticMutationProps<TData, TVariables, TOptimisticDataArray> = {
    mutationFn: MutationFunction<TData, TVariables>
    optimistic: OptimisticFunction<TOptimisticDataArray, TVariables>
    }

    export function useOptimisticMutation<
    TData,
    TError,
    TVariables,
    TOptimisticDataArray extends unknown[]
    >({
    mutationFn,
    optimistic,
    }: OptimisticMutationProps<TData, TVariables, TOptimisticDataArray>) {
    const queryClient = useQueryClient()

    return useMutation<TData, TError, TVariables, MutationContext>({
    mutationFn,
    onMutate: async (variables) => {
    const results = []
    const handlers = optimistic(variables)

    for (const handler of handlers) {
    if ('queryKey' in handler) {
    const {queryKey, updater} = handler
    let didCancelFetch = false

    // If query is currently fetching, we cancel it to avoid overwriting our optimistic update.
    // This would happen if query responds with old data after our optimistic update is applied.
    const isFetching = queryClient.getQueryState(queryKey)?.fetchStatus === 'fetching'
    if (isFetching) {
    await queryClient.cancelQueries(queryKey)
    didCancelFetch = true
    }

    // Get previous data before optimistic update
    const previousData = queryClient.getQueryData(queryKey)
    // Rollback function we call if mutation fails
    const rollback = () => queryClient.setQueryData(queryKey, previousData)
    // Invalidate function to call after mutation is done if we cancelled a fetch.
    // This ensures that we get both the optimistic update and fresh data from the server.
    const invalidate = () => queryClient.invalidateQueries(queryKey)

    // Update data in React Query cache
    queryClient.setQueryData(queryKey, updater)

    // Add to results that we read in onError and onSettled
    results.push({
    rollback,
    invalidate,
    didCancelFetch,
    })
    } else {
    // If no query key then we're not operating on the React Query cache
    // We expect to have a `getData` and `setData` function
    const {getData, setData, updater} = handler
    const previousData = getData()
    const rollback = () => setData(previousData)
    setData(updater)
    results.push({
    rollback,
    })
    }
    }

    return {results}
    },
    // On error revert all queries to their previous data
    onError: (error, variables, context) => {
    if (context?.results) {
    context.results.forEach(({rollback}) => {
    rollback()
    })
    }
    },
    // When mutation is done invalidate cancelled queries so they get refetched
    onSettled: (data, error, variables, context) => {
    if (context?.results) {
    context.results.forEach(({didCancelFetch, invalidate}) => {
    if (didCancelFetch && invalidate) {
    invalidate()
    }
    })
    }
    },
    })
    }