Skip to content

Commit

Permalink
feat(useMutation): add hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
Elise Patrikainen authored and Elise Patrikainen committed Mar 16, 2024
1 parent 38b6cfb commit c44af13
Show file tree
Hide file tree
Showing 6 changed files with 145 additions and 7 deletions.
4 changes: 2 additions & 2 deletions playground/db.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,15 @@
"color": "Sienna",
"imagesrc": "https://tailwindui.com/img/ecommerce-images/shopping-cart-page-01-product-01.jpg",
"price": 24,
"availability": -2
"availability": 1
},
{
"id": 4,
"name": "Basic G",
"color": "Grey",
"imagesrc": "https://tailwindui.com/img/ecommerce-images/product-page-01-related-product-03.jpg",
"price": 24,
"availability": 11
"availability": 0
},
{
"id": 5,
Expand Down
4 changes: 3 additions & 1 deletion playground/src/api/products.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type Options, mande } from 'mande'
import { delay } from './utils'

export const products = mande('http://localhost:7777/products', {})

Expand Down Expand Up @@ -26,7 +27,8 @@ export function getProductById(id: string | number, options?: Options<'json'>) {
return products.get<ProductT>(id, options)
}

export async function changeProductAvailability(product: ProductListItem, options?: Options<'json'>) {
export async function changeProductAvailability(product: ProductListItem, options?: Options<'json'>, _delay?: number) {
await delay(_delay ?? 0)
if (product.availability < 1) {
throw new Error('Product not available')
}
Expand Down
1 change: 1 addition & 0 deletions playground/src/api/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
25 changes: 22 additions & 3 deletions playground/src/pages/ecom/item/[id].vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { StarIcon } from '@heroicons/vue/20/solid'
import { HeartIcon } from '@heroicons/vue/24/outline'
import { useMutation, useQuery } from '@pinia/colada'
Expand All @@ -19,9 +20,27 @@ const { data: item, isPending } = useQuery({
staleTime: 15000,
})
const itemAvailability = ref()
watch(() => item.value?.availability, value => itemAvailability.value = value)
const { mutate: bookProduct } = useMutation({
keys: product => [['items', product.id]],
mutation: (product: ProductListItem) => changeProductAvailability(product),
keys: product => [['items'], ['items', product.id]],
mutation: (product: ProductListItem) => changeProductAvailability(product, undefined, 1500),
// NOTE: the optimistic update only works if there are no parallele updates
onMutate: (product) => {
itemAvailability.value = product.availability - 1
},
onError() {
itemAvailability.value = item.value?.availability
},
onSuccess(data) {
// TODO: find a better usecase
console.log('Success hook called', data)
},
onSettled() {
// TODO: find a better usecase
console.log('Settled hook called')
},
// onMutate: async () => {
// // Cancel any outgoing refetches
// // (so they don't overwrite our optimistic update)
Expand Down Expand Up @@ -107,7 +126,7 @@ const { mutate: bookProduct } = useMutation({
/>
</div>

<div>Availability: {{ item?.availability }}</div>
<div>Availability: {{ itemAvailability }}</div>

<div class="flex mt-10">
<button
Expand Down
83 changes: 82 additions & 1 deletion src/use-mutation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('useMutation', () => {
render: () => null,
setup() {
return {
...useMutation<TResult>({
...useMutation<TResult, TParams>({
...options,
// @ts-expect-error: generic unmatched but types work
mutation,
Expand All @@ -56,4 +56,85 @@ describe('useMutation', () => {

expect(wrapper.vm.data).toBe(42)
})

it('invokes the `onMutate` hook', async () => {
let foo
const { wrapper } = mountSimple({
mutation: async (arg: number) => {
await delay(0)
return arg + 42
},
onMutate: arg => foo = arg,
})

expect(foo).toBeUndefined()
wrapper.vm.mutate(24)
expect(foo).toBe(24)
})

it('invokes the `onError` hook', async () => {
let foo
const { wrapper } = mountSimple({
mutation: async () => {
await delay(0)
throw new Error('bar')
},
onError(err) {
foo = err.message
},
})

expect(wrapper.vm.mutate()).rejects.toThrow()
expect(foo).toBeUndefined()
await runTimers()
expect(foo).toBe('bar')
})

it('invokes the `onSuccess` hook', async () => {
let foo
const { wrapper } = mountSimple({
onSuccess(val) {
foo = val
},
})

wrapper.vm.mutate()
expect(foo).toBeUndefined()
await runTimers()
expect(foo).toBe(42)
})

describe('invokes the `onSettled` hook', () => {
it('on success', async () => {
let foo
const { wrapper } = mountSimple({
onSettled() {
foo = 24
},
})

wrapper.vm.mutate()
expect(foo).toBeUndefined()
await runTimers()
expect(foo).toBe(24)
})

it('on error', async () => {
let foo
const { wrapper } = mountSimple({
mutation: async () => {
await delay(0)
throw new Error('bar')
},
onSettled() {
foo = 24
},
})

expect(wrapper.vm.mutate()).rejects.toThrow()
expect(foo).toBeUndefined()
await runTimers()
expect(foo).toBe(24)
})
})
})
35 changes: 35 additions & 0 deletions src/use-mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ export interface UseMutationOptions<
*/
keys?: _MutationKeys<TParams, TResult>

/**
* Hook to execute a callback when the mutation is triggered
*/
onMutate?: (...args: TParams) => void

/**
* Hook to execute a callback in case of error
*/
onError?: (error: any) => void

/**
* Hook to execute a callback in case of error
*/
onSuccess?: (result: TResult) => void

/**
* Hook to execute a callback in case of error
*/
onSettled?: () => void

// TODO: invalidate options exact, refetch, etc
}

Expand Down Expand Up @@ -85,10 +105,17 @@ export function useMutation<
function mutate(...args: TParams) {
status.value = 'loading'

if (options.onMutate) {
options.onMutate(...args)
}

// TODO: AbortSignal that is aborted when the mutation is called again so we can throw in pending
const promise = (pendingPromise = options
.mutation(...args)
.then((_data) => {
if (options.onSuccess) {
options.onSuccess(_data)
}
if (pendingPromise === promise) {
data.value = _data
error.value = null
Expand All @@ -111,7 +138,15 @@ export function useMutation<
error.value = _error
status.value = 'error'
}
if (options.onError) {
options.onError(_error)
}
throw _error
})
.finally(async () => {
if (options.onSettled) {
options.onSettled()
}
}))

return promise
Expand Down

0 comments on commit c44af13

Please sign in to comment.