Source

tools/hooks/useApiFetch.ts

//
// useApiFetch custom hook
//

import { useEffect, useState } from 'react'
import { v1 as Uuid1, NIL as NilUuid } from 'uuid'
import { useApi } from '.'
import { ApiInterface } from '../../interfaces'

type NullFunction = () => void

interface ApiFetchOptions {
    ready?: () => boolean
}

interface ApiFetchControls {
    addError: (error: Error|string|any) => void,
    logId: string
}

export enum FetchStatus {
    INIT = "INIT",
    FETCH = "FETCH",
    DATA = "DATA",
    ERROR = "ERROR"
}

/**
Custom hook for retrieving data from a Spark API Interface.

This hook consolidates some of the chore-work involved with
getting data from an API. As well as locating the API interface
and managing the data you're expecting back from the API call,
you have to handle the asynchronous nature of the fetch, do
something with potential errors, and make sure you can render
something valid and informative for the user at every moment.
`useApiFetch` abstracts away most of this management stuff.

## Basic usage

The simplest usage looks like this:

```
require { useApiFetch, FetchStatus } from '@jcu/spark'

// ...

const [errors, data, status] = useApiFetch(
    'api-name', '1',
    (api) => api.getSomeData()
)

// ...

if (status === FetchStatus.INIT) { return "...preparing..."}
if (status === FetchStatus.FETCH) { return "...loading..."}
if (status === FetchStatus.ERROR) {
    console.log(errors)
    return "Oh no! Errors!"
}
if (status === FetchStatus.DATA) {
    return <InfoRenderer info={data} />
}

```
This usage of the hook has three arguments and creates three
`const` variables.

The first two args are _apiName_ and _apiVersion_. These are the
name and version of your API Interface class, and are used by
the hook to retrieve the interface from the ApiManager.

The third argument is a callback you write, that recieves an
instance of your requested API interface as the first argument,
and should make the api calls required to retrieve and then
return whatever data you need from the API. In the example, it's
calling an api interface function `getSomeData()` and returning
whatever that returns. It's okay for this function to take some
time; the hook will arrange things so that it happens in the
background.

After running this code you've created three `const` variables
in your component:

**`errors`** is an array of `Error` objects describing problems
that occurred during the api call. If everything goes well, this
is zero length.

**`data`** will eventually hold the data returned by your
callback. It's initially `undefined`.

**`status`** tells you what status the fetch is in, out of four
possible values. They're selected from the `FetchStatus` enum.
The diagram below shows the four statuses and how your hook
instance can move between them. You should use the value of
`status` to decide what gets rendered. When `status` equals
`FetchStatus.DATA`, then the data has arrived and you can show
it to the user.

```
Status Diagram
                .--------------------.
                |       ERROR        |
                '--------------------'
                    A           | deps change OR
                    |           | user refresh
           net fail |           |
        OR bad data |           V
        .------------------------------------.
        |              FETCHING              |
        '------------------------------------'
            A                      A      | good data
            |                      |      | arrives
       deps |       deps change OR |      |
    resolve |         user refresh |      V
    .--------------.        .--------------------.
    |     init     |        |      HAS DATA      |
    '--------------'        '--------------------'
```

Note that it's possible to be in the error state and still have
data returned (for example, it's possible to add an error from
your callback, but still return data); in that case, the state
will be ERROR but your data variable will still include data
returned by your callback.

## Use other vars in API calls

You can include other variables, e.g. useState() vars you're
tracking, directly in your API callback. This adds a dependency
to the hook, so you will also have to give the hook a dependency
array as a fourth argument.

```
const [hpMovieId, setHpMovieId] = getState(1)
// ...
const [errors, hpTitle, status] = useApiFetch(
    'hp-movies', '1',
    (api) => api.getMovieInfo('title', hpMovieId),
    [hpMovieId]
)
```
Here the `getMovieInfo()` api call needs the field to get
(`'title'`), and the movie id (initially `1`). So after the api
returns, we can expect `data` to equal `"Harry Potter and the
Philosopher's Stone"`.

Since you included `hpMovieId` in the dependency array, if that
variable changes, the hook will automatically re-run your API
fetch.

## Don't fetch until everything's ready

If you need to hold off on your fetch until conditions are right,
you can supply a function to say when you're ready to go. To do
this provide an object as the fifth argument, with a `ready` key
that's your function. Return false from this function if you
don't want to hit the API yet.

E.g. if you start a user's movie selection as null, you will want
the user to choose a movie, _then_ you can get the title.

```
const [hpMovieId, setHpMovieId] = getState(null)
// ...
const [errors, hpTitle, status] = useApiFetch(
    'hp-movies', '1',
    (api) => api.getMovieInfo('title', hpMovieId),
    [hpMovieId],
    {
        ready: () => { hpMovieId !== null }
    }
)
```
Now, no api call happens until you set the `hpMovieId` to
something other than null -- and then, the hook notices the
change and automatically kicks off the fetch. Note that any
variable you refer to inside your `ready` function must be
listed in your dependency array, so the hook knows when to check
again.

## Other things you get when API-ing

Your API callback receives an API Interface instance as its
first argument; you can accept a second argument to get access
to some other stuff.

Also note that here, since I'm using `await` in my callback, I
need to declare it as an `async` function.

```
const [errors, hpTitle, status] = useApiFetch(
    'hp-movies', '1',
    async (api, controls) => {
        // controls.logId is a fresh UUID you should use in your
        // `X-JCU-Log-Id` header.
        const myLogId = controls.logId

        const result = await api.getMovieInfo('title', hpMovieId, myLogId)

        // controls.addError(err) is how you signal that there
        // was a problem. Give it a string or an `Error` if have
        // one, but anything `.toString()`-ible is okay.
        if (result.serverProblemReport) {
            controls.addError(data.serverProblemReport)
        } else {
            return result
        }
    }
    [hpMovieId]
)
```

## Force a refresh

You hardly ever need to manually invoke an API refresh; React's
ability to detect dependency changes means things will refresh
whenever they need to. Sometimes though you know that server
data has changed, and want to imperatively invoke a new data
fetch.

To do that, add a fourth element to your array of `const`s:
```
const [errors, hpTitle, status, refresh] = useApiFetch(
```
..then later, you can invoke `refresh()` to force your api fetch
to happen. The hook still respects your `ready` function, so
if your refresh isn't happening, check that you aren't passing
in a `ready` that returns false.

@category Hooks
@module useApiFetch
*/
export const useApiFetch = function<T extends ApiInterface>(
        apiName: string,
        apiVersion: string,
        callApi: (api:T, controls:ApiFetchControls) => any,
        dependencies: Array<any> = [],
        options: ApiFetchOptions = {}
):[Error[], any, FetchStatus, NullFunction] {

     // list of errors; returned direct to the user
    const [errors, setErrors] = useState<Error[]>([])
    // data, presumeably from the api; returned direct to the user
    const [data, setData] = useState<any>(undefined)
    // user's deps that were used to get whatever is in `data`,
    // stored as a JSON string. Used to track whether they've changed or not
    const [dataDeps, setDataDeps] = useState<string>(null)
    // have we done our first run? used to calculate status
    const [preFirstRun, setPreFirstRun] = useState<boolean>(true)
    // are we currently waiting on a user's loading callback? used by status
    const [loading, setLoading] = useState<boolean>(false)
    // the logId that should be used for all API interactions
    const [logId, setLogId] = useState<string>(NilUuid)
    // a way to force a re-run of the fetch; given to user because we trust them not to abuse it
    const refresh:NullFunction = ()=>{ setDataDeps(null) }

    // user's ready func (if not provided, we use a func that returns true)
    const ready = options.ready || ( () => true )

    // get the api they asked for
    const api = useApi<T>(apiName, apiVersion)

    function addError(newError: any):void {
        if (!(newError instanceof Error)) {
            // if the thing thrown isn't an error, make it into one
            newError = Error('' + newError)
        }
        setErrors( (original) => [...original, newError])
    }

    useEffect( ()=> {
        // CAN we get data?
        // we can if we have the api, and we're not already getting data,
        // and the user's supplied ready function returns true
        if (api && !loading && ready()) {
            // SHOULD we start getting data?
            // get data if we haven't yet, OR the user's deps have changed
            // since the last fetch.
            // (dataDeps defaults to null, so comparing it to the user's
            // supplied deps -- default [] -- will run this for new deps
            // and also catch the first run)
            if (!depsMatch(dependencies, dataDeps)) {
                setLoading(true) // we're loading starting now
                setLogId(Uuid1())
                setPreFirstRun(false)

                // removing this line would mean any "old" data stays while the refresh happens
                setData(undefined) // blank out the data while we're refreshing it

                // doing this now means a refresh() call during the fetch will force another
                // fetch once it completes. If we did this after awaiting the user's function,
                // a refresh() during a fetch would NOT cause a second load.
                setDataDeps(serialise(dependencies)) // remember the user deps we're using on this fetch

                ;(async ()=> { // run the rest of the fetch in the background
                    setErrors([]) // start with no errors
                    try {
                        // call the user's handler, awaiting the result
                        const userData = await callApi(api, {addError, logId})
                        setData(userData) // whatever the user returned, put it in data

                    } catch (e) {
                        // if there was an exception thrown:
                        setData(undefined)
                        addError(e)

                    } finally {
                        setLoading(false) // loading indicator off
                    }
                })() // ...immediately invoke this anonymous async function
            }
        }
    // our deps are our own stuff, plus the user's dependencies
    }, [data, loading, api, callApi, dataDeps, ready, ...dependencies])

    // finally, we can hand back the errors, data, loading state, and the run-again function
    let status:FetchStatus = FetchStatus.INIT
    if      (preFirstRun)        { status = FetchStatus.INIT }
    else if (loading)            { status = FetchStatus.FETCH }
    else if (errors.length > 0)  { status = FetchStatus.ERROR }
    else                         { status = FetchStatus.DATA  }

    return [errors, data, status, refresh]
}
// -------------------------------------------------------------
function depsMatch(deps1: Array<any> | string, deps2: Array<any> | string): boolean {
    // Compare the arrays. true/false for same/different.
    return (serialise(deps1) === serialise(deps2))
}
// -------------------------------------------------------------
function serialise(dependency: Array<any> | string): string {
    if (typeof dependency === 'string') {
        return dependency
    } else {
        return JSON.stringify(dependency)
    }
}