DEV Community

loading...
Cover image for Using CRUD operations with React SWR for mutating REST API cache

Using CRUD operations with React SWR for mutating REST API cache

squashbugler profile image John Grisham ・11 min read

To support me please read this tutorial at its original posting location on Medium:
Using CRUD operations with React SWR for mutating REST API cache


SWR for making fetch requests

Vercel has made some great libraries and frameworks in the past so it's no surprise that the SWR library would be any different. I'm going to show you how to fetch and manipulate data from a REST API with Vercel's SWR library. This post has a quick overview of the Vercel library, but if you want to learn more about the library and how it works, you can read the full documentation here.

SWR: React Hooks for Data Fetching

What is SWR?

The idea behind SWR which stands for stale while revalidating is defined in the docs as such. SWR is a strategy to first return the data from cache (stale), then send the fetch request (revalidate), and finally, come with the up-to-date data. So what does this have to do with CRUD? In case you didn't know CRUD is a set of operations that are performed on data and it is shorthand for create, read, update and delete. By default, SWR will perform the read part of this for you by returning the result of a fetch request. But if you want to expand this you will have to mutate the cache from that request. That's why I created a useCrud hook that will help us do just that. I also incorporate Typescript to ensure that the proper keys are used when updating the cache so you will need to have that set up as well.

Setting things up

So first thing is to install SWR, to do this run:

npm install swr
or
yarn add swr
Enter fullscreen mode Exit fullscreen mode

This will add the SWR library to your project. Next, we will add a configuration provider for our app. This will provide the global configuration for SWR when we make requests. I have a contexts folder where I store contexts like this.

import * as React from 'react'
import { SWRConfig } from 'swr'

const swrConfig = {
 revalidateOnFocus: false,
 shouldRetryOnError: false
}

export const SWRConfigurationProvider: React.FC = ({ children }) => <SWRConfig value={swrConfig}>{children}</SWRConfig>
Enter fullscreen mode Exit fullscreen mode

This will need to wrap around your app root, for me that is in the pages/_app.tsx file because I'm using NextJS but it can work in another framework like Gatsby as long as it wraps your app globally. Feel free to change the settings as needed for your project.

R you ready to read some data?

Now we will need to start implementing the fetch that will form the basis of the hook. Here is an example of how fetching works in SWR.

const fetcher = useCallback(
 async (url: string) => {
 const response = await fetch(url)
 return response as T[]
 },
 []
 )

const { data, error, isValidating, mutate } = useSWR(url, fetcher, {
 fetchOptions
 })
Enter fullscreen mode Exit fullscreen mode

The useSWR hook is pretty straight forward it takes a URL and a 'fetcher' which is the function that will perform the request. The URL is passed to the fetcher to make the request and you can also provide some nifty options. SWR will return some things back for you the first is the data that was returned, an error status if there is one, a mutate function, and an isValidating boolean that will tell you if the data is fresh or not. You can think of the isValidating flag as a loading indicator; it isn't quite the same thing but for my purposes it is.

Go ahead and create a use-crud.tsx file wherever you put your custom hooks and add this to start.

import useSWR, { ConfigInterface } from 'swr'
import { useCallback } from 'react'

// T is the response type
// K is the request type which defaults to T
export function useCrud<T, K = T>(url: string, key: keyof T, fetchOptions?: ConfigInterface) {
 const fetch = useCallback(
 async (url: string) => {
 const response = await fetch(url)
 return response as T[]
 },
 []
 )

const { data, error, isValidating, mutate } = useSWR(url, fetch, {
 fetchOptions
 })

return {
 fetch: {
 data,
 error,
 loading: isValidating,
 mutate
 }
 }
}
Enter fullscreen mode Exit fullscreen mode

Making it user friendly

I'll go over the parameters and types later but for now all you need to know is that we will be able to pass a URL to this hook and it will give us the data and the methods to perform CRUD operations on that data. There's just one problem that I ran into. Sometimes the response is too quick for my app since we have the cached data to fall back on so I added a loading state and timeout to make the request take at least half a second. This will improve the user experience.

import { useCallback, useEffect, useState } from 'react'
import useSWR, { ConfigInterface } from 'swr'

// T is the response type
// K is the request type which defaults to T
export function useCrud<T, K = T>(url: string, key: keyof T, fetchOptions?: ConfigInterface) {
const [loading, setIsLoading] = useState(true)

const loadingTimeout = () => {
 setIsLoading(false)
 }

const fetch = useCallback(
 async (url: string) => {
 const response = await fetch(url)
 return response as T[]
 },
 []
 )

const { data, error, isValidating, mutate } = useSWR(url, fetch, {
 fetchOptions
 })

useEffect(() => {
 if (isValidating) {
 setIsLoading(true)
 return
 }

setTimeout(loadingTimeout, 500)
 }, [isValidating])

return {
 fetch: {
 data,
 error,
 loading,
 mutate
 }
 }
}
Enter fullscreen mode Exit fullscreen mode

There's one little quirk with SWR that I need to mention. When there is no data from a request an empty object is returned; that's not really what I want so I added an extra step to check if the data is empty. For that I will use lodash, go ahead and install it if you haven't already. If the object is empty I will return an empty array instead, update your imports to add this.

import { isArray, isEmpty } from 'lodash'
import { useCallback, useEffect, useMemo, useState } from 'react'
Enter fullscreen mode Exit fullscreen mode

We'll need the isArray method later for the CRUD operations and we'll be memoizing the result of the data check. Add this above the return statement.

const memoizedData = useMemo(() => (!isEmpty(data) ? data : []), [data])
Enter fullscreen mode Exit fullscreen mode

And then return memoizedData instead of data.

return {
 fetch: {
 data: memoizedData,
 error,
 loading,
 mutate
 }
 }
Enter fullscreen mode Exit fullscreen mode

C what I did there

Now the moment you've been waiting for, we're going to start modifying the data but before we do that let me explain the Typescript parameters of this function. The T generic type is the type of data we'll be expecting to get back and the K generic type is the type of data we will be using to perform the create operation. In most cases, this will be the same but in case we need to perform some operations on that data before sending it we will use a different type. As you can see it defaults to T anyway if we don't pass anything. The key in the parameters is a key of the type T which means any props on the type can be used but we need to tell typescript what the index key is so we can mutate the cached data from the fetch. The create operation will look like this.

const create = useCallback(
 async (newObject: K, shouldRevalidate = false) => {
 const response = await fetch(url, {
 body: newObject,
 method: 'POST'
 })

const result = response as T

if (data && mutate) {
 let newData = data
 if (isArray(data)) {
 newData = data.concat(result)
 }

await mutate([new Set(newData)], shouldRevalidate)
 }

return result
 },
 [url, data, mutate]
 )
Enter fullscreen mode Exit fullscreen mode

Two is better than one

This will create a new object in our URL post method. If we have data it will mutate its cache if we don't we'll just return the result of the post. There is an additional check to see if the data is an array, if it is we will add the new object to the data array if it isn't we will add a new set of data and skip revalidation. I went ahead and added a parameter for revalidation that can be overridden if we want the new data and not just the cache. This will call the mutate function we got earlier and allow us to mutate the cache with the new data and return an optimistic response of what the new array should look like; all without fetching the data again. But this method will only work for creating a single instance so we will need one for creating multiple objects as well.

const createMultiple = useCallback(
 async (newObjects: K[], shouldRevalidate = false) => {
 const response = await fetch(url, {
 body: newObjects,
 method: 'POST'
 })

const result = response as T[]

if (data && mutate) {
 await mutate([data, result], shouldRevalidate)
 }

return result
 },
 [url, data, mutate]
 )
Enter fullscreen mode Exit fullscreen mode

Gimme the D

This separate method will handle creating more than one object. One improvement would be to combine these but this will work for the purpose of the tutorial. Next, we'll handle the removal operation of CRUD. The function should look like this.

const remove = useCallback(
 async (body: number, shouldRevalidate = false) => {
 const response = await fetch(url, {
 body,
 method: 'DELETE'
 })
 const result = response as T

if (data && mutate) {
 if (isArray(result)) {
 const updatedObjects = [data].filter((current) => {
 const isDeleted = result.find((result) => result[key] === current[key])
 return !isDeleted
 })

 await mutate(result.length === 0 ? [] : updatedObjects, shouldRevalidate)
 } else {
 const deletedIndex = data.findIndex((object) => object[key] === result[key])

if (deletedIndex >= 0) {
 const updatedObjects = [data]
 updatedObjects.splice(deletedIndex, 1)

        await mutate(updatedObjects, shouldRevalidate)
       }
    }
 }

return result
 },
 [url, data, key, mutate]
 )
Enter fullscreen mode Exit fullscreen mode

This will take a number for the key you are modifying so you can get that from the data you got from the original fetch and parse it according to whichever item you are removing. If the result of this operation is an array then we will find each item in the data that matches the key and remove it from the list. Otherwise, we will have to find the index of the object that was deleted and if it is in the list remove that index. One important note is that each of these requests should return the value of whatever object was manipulated so that we can update the cache. Removing multiple objects is very similar.

const removeMultiple = useCallback(
 async (ids: number[], shouldRevalidate = false) => {
 const response = await fetch(url, {
 body: ids,
 method: 'DELETE'
 })
 const results = response as T[]

if (data && mutate) {
 const updatedObjects = [data].filter((current) => {
 const isDeleted = results.find((result) => result[key] === current[key])
 return !isDeleted
 })

        await mutate(updatedObjects, shouldRevalidate)

        return results
       }
   },
 [url, data, key, mutate]
 )
Enter fullscreen mode Exit fullscreen mode

U know what comes next

The update part of CRUD is a little different since the SQL server can throw an error if the rows being updated aren't different. For this, you should probably have some validation on the front end to make sure that doesn't happen but just in case I will make a check for it here using a method I stole. Create a helper method called get-object-difference.ts somewhere you can easily access it.

import { isEqual } from 'lodash'

/*
 * Compare two objects by reducing an array of keys in obj1, having the
 * keys in obj2 as the initial value of the result. Key points:
 *
 * ' All keys of obj2 are initially in the result.
 *
 * ' If the loop finds a key (from obj1, remember) not in obj2, it adds
 * it to the result.
 *
 * ' If the loop finds a key that is both in obj1 and obj2, it compares
 * the value. If it's the same value, the key is removed from the result.
 */
export function getObjectDifference(obj1: any, obj2: any) {
 const diff = Object.keys(obj1).reduce((result, key) => {
 if (!obj2.hasOwnProperty(key)) {
 result.push(key)
 }
 return result
 }, Object.keys(obj2))

return Object.fromEntries(
 diff.map((key) => {
 return [key, obj2[key]]
 })
 )
}
Enter fullscreen mode Exit fullscreen mode

This method will return an object of the difference between two objects otherwise it will return an empty object if there is none. Go ahead and import it into the useCrud file and add the update method.

const update = useCallback(
 async (updatedObject: T, shouldRevalidate = false): Promise<T> => {
 const currentObjectIndex = data.findIndex((object) => object[key] === updatedObject[key])
 const currentObject = data[currentObjectIndex]
 const diff = currentObject ? getObjectDifference(currentObject, updatedObject) : null

if (!diff) {
 throw new Error('Update Failed')
 }

if (isEmpty(diff)) {
 return currentObject
 }

const response = await fetch(url, {
 body: { diff, id: updatedObject[key] },
 method: 'PATCH'
 })

if (data && mutate) {
 const updatedObjects = [data]
 updatedObjects.splice(currentObjectIndex, 1, response)
 await mutate(updatedObjects, shouldRevalidate)
 }

return response as T
 },
 [url, data, mutate, key]
 )
Enter fullscreen mode Exit fullscreen mode

This will check the cache for the current object you are modifying and get the difference between the old object and the new one. If the current object doesn't exist in the cache it will throw an error. Otherwise, if there is no difference it will just return the current object and not execute the fetch request to patch. If there is a difference it will pass the difference and the updated object's id as whatever key you specified earlier on the updated object. It will then go ahead and perform the mutate on the cached data, updating multiple objects is slightly different.

const updateMultiple = useCallback(
 async (updatedObjects: T[], shouldRevalidate = false): Promise<T[]> => {
 const currentObjects = data.filter((object) => updatedObjects.find((updated) => object[key] === updated[key]))

if (!currentObjects || currentObjects <= 0) {
 throw new Error('Update Failed')
 }

const diffs = currentObjects.map((currentObject) => {
 const updatedObject = updatedObjects.find((updated) => updated[key] === currentObject[key])
 return { getObjectDifference(currentObject, updatedObject), id: updatedObject[key] }
 })

if (diffs.length <= 0) {
 return currentObjects
 }

const response = await fetch(url, {
 body: { diffs },
 method: 'PATCH'
 })

if (data && mutate) {
 const updatedObjects = [data].map((current) => {
 if (current[key] === response[key]) {
 return response
 }

   return current
 })

   await mutate(updatedObjects, shouldRevalidate)
 }

return response as T[]
 },
 [url, data, mutate, key]
 )
Enter fullscreen mode Exit fullscreen mode

This will run the difference check on all the objects and instead pass an array of object differences in the body. All of these implementations are of course specific to my API routes but they could easily be modified to work with your use case.

Wrapping up this spelling lesson

Phew! If you made it this far I owe you a drink but since I can't buy you one right now instead I'll give you the full code.

import { isArray, isEmpty } from 'lodash'
import { useCallback, useEffect, useMemo, useState } from 'react'
import useSWR, { ConfigInterface } from 'swr'
import { getObjectDifference } from '../where-ever-you-put-this-earlier'

// T is the response type
// K is the request type which defaults to T
export function useCrud<T, K = T>(url: string, key: keyof T, fetchOptions?: ConfigInterface) {
const [loading, setIsLoading] = useState(true)

const loadingTimeout = () => {
setIsLoading(false)
}

const fetch = useCallback(
async (url: string) => {
const response = await fetch(url)
return response as T[]
},[])

const { data, error, isValidating, mutate } = useSWR(url, fetch, {fetchOptions})

useEffect(() => {
if (isValidating) {
setIsLoading(true)
return
}setTimeout(loadingTimeout, 500)},
[isValidating])

const create = useCallback(
async (newObject: K, shouldRevalidate = false) => {
const response = await fetch(url, {
body: newObject,
method: 'POST'
})

const result = response as T
if (data && mutate) {
let newData = data
if (isArray(data)) {
newData = data.concat(result)
}

await mutate([new Set(newData)], shouldRevalidate)
}

return result
},[url, data, mutate])

const createMultiple = useCallback(async (newObjects: K[], shouldRevalidate = false) => {
const response = await fetch(url, {
body: newObjects,
method: 'POST'
})

const result = response as T[]
if (data && mutate) {
await mutate([data, result], shouldRevalidate)}
return result
},[url, data, mutate])

const remove = useCallback(async (body: number | unknown, shouldRevalidate = false) => {
const response = await fetch(url, {
body,
method: 'DELETE'
})

const result = response as T
if (data && mutate) {
if (isArray(result)) {
const updatedObjects = [data].filter((current) => {
const isDeleted = result.find((result) => result[key] === current[key])
return !isDeleted
})

await mutate(result.length === 0 ? [] : updatedObjects, shouldRevalidate)
} else {

const deletedIndex = data.findIndex((object) => object[key] === result[key])
if (deletedIndex >= 0) {
const updatedObjects = [data]
updatedObjects.splice(deletedIndex, 1)

    await mutate(updatedObjects, shouldRevalidate)
  }
Enter fullscreen mode Exit fullscreen mode

}
}

return result
},[url, data, key, mutate])

const removeMultiple = useCallback(async (ids: number[], shouldRevalidate = false) => {
const response = await fetch(url, {
body: ids,
method: 'DELETE'
})

const results = response as T[]
if (data && mutate) {
const updatedObjects = [data].filter((current) => {
const isDeleted = results.find((result) => result[key] === current[key])
return !isDeleted
})

 await mutate(updatedObjects, shouldRevalidate)

 return results
Enter fullscreen mode Exit fullscreen mode

}
},
[url, data, key, mutate])

const update = useCallback(async (updatedObject: T, shouldRevalidate = false): Promise<T> => {

const currentObjectIndex = data.findIndex((object) => object[key] === updatedObject[key])

const currentObject = data[currentObjectIndex]
const diff = currentObject ? getObjectDifference(currentObject, updatedObject) : null

if (!diff) {
throw new Error('Update Failed')
}

if (isEmpty(diff)) {
return currentObject
}

const response = await fetch(url, {
body: { diff, id: updatedObject[key] },
method: 'PATCH'
})
if (data && mutate) {
const updatedObjects = [data]
updatedObjects.splice(currentObjectIndex, 1, response)
await mutate(updatedObjects, shouldRevalidate)
}
return response as T
},[url, data, mutate, key])

const updateMultiple = useCallback(async (updatedObjects: T[], shouldRevalidate = false): Promise<T[]> => {
const currentObjects = data.filter((object) => updatedObjects.find((updated) => object[key] === updated[key]))

if (!currentObjects || currentObjects <= 0) {
throw new Error('Update Failed')
}

const diffs = currentObjects.map((currentObject) => {
const updatedObject = updatedObjects.find((updated) => updated[key] === currentObject[key])

return { getObjectDifference(currentObject, updatedObject), id: updatedObject[key] }
})

if (diffs.length <= 0) {
return currentObjects
}

const response = await fetch(url, {
body: { diffs },
method: 'PATCH'
})

if (data && mutate) {
const updatedObjects = [data].map((current) => {
if (current[key] === response[key]) {
return response
}
return current
})

await mutate(updatedObjects, shouldRevalidate)
}
return response as T[]
},[url, data, mutate, key])

const memoizedData = useMemo(() => (!isEmpty(data) ? filterDeleted<T>(data) : []), [data])

return {
create,
createMultiple,
fetch: { data: memoizedData, error, loading, mutate },
remove,
removeMultiple,
update,
updateMultiple
}
}

Enter fullscreen mode Exit fullscreen mode




Conclusion

Congratulations you've made it through this tutorial, this hook should give you all the functionality you need to perform CRUD operations with a custom restful API. This implementation is specific to my API so you may have to modify it for your use purposes but it is generic enough to be used in most cases. Thanks for joining me, I hope you enjoyed this load of CRUD.

Please follow me on Twitter: @SquashBugler

Discussion

pic
Editor guide