Source

tools/hooks/useApiFetch.ts

  1. //
  2. // useApiFetch custom hook
  3. //
  4. import { useEffect, useState } from 'react'
  5. import { v1 as Uuid1, NIL as NilUuid } from 'uuid'
  6. import { useApi } from '.'
  7. import { ApiInterface } from '../../interfaces'
  8. type NullFunction = () => void
  9. interface ApiFetchOptions {
  10. ready?: () => boolean
  11. }
  12. interface ApiFetchControls {
  13. addError: (error: Error|string|any) => void,
  14. logId: string
  15. }
  16. export enum FetchStatus {
  17. INIT = "INIT",
  18. FETCH = "FETCH",
  19. DATA = "DATA",
  20. ERROR = "ERROR"
  21. }
  22. /**
  23. Custom hook for retrieving data from a Spark API Interface.
  24. This hook consolidates some of the chore-work involved with
  25. getting data from an API. As well as locating the API interface
  26. and managing the data you're expecting back from the API call,
  27. you have to handle the asynchronous nature of the fetch, do
  28. something with potential errors, and make sure you can render
  29. something valid and informative for the user at every moment.
  30. `useApiFetch` abstracts away most of this management stuff.
  31. ## Basic usage
  32. The simplest usage looks like this:
  33. ```
  34. require { useApiFetch, FetchStatus } from '@jcu/spark'
  35. // ...
  36. const [errors, data, status] = useApiFetch(
  37. 'api-name', '1',
  38. (api) => api.getSomeData()
  39. )
  40. // ...
  41. if (status === FetchStatus.INIT) { return "...preparing..."}
  42. if (status === FetchStatus.FETCH) { return "...loading..."}
  43. if (status === FetchStatus.ERROR) {
  44. console.log(errors)
  45. return "Oh no! Errors!"
  46. }
  47. if (status === FetchStatus.DATA) {
  48. return <InfoRenderer info={data} />
  49. }
  50. ```
  51. This usage of the hook has three arguments and creates three
  52. `const` variables.
  53. The first two args are _apiName_ and _apiVersion_. These are the
  54. name and version of your API Interface class, and are used by
  55. the hook to retrieve the interface from the ApiManager.
  56. The third argument is a callback you write, that recieves an
  57. instance of your requested API interface as the first argument,
  58. and should make the api calls required to retrieve and then
  59. return whatever data you need from the API. In the example, it's
  60. calling an api interface function `getSomeData()` and returning
  61. whatever that returns. It's okay for this function to take some
  62. time; the hook will arrange things so that it happens in the
  63. background.
  64. After running this code you've created three `const` variables
  65. in your component:
  66. **`errors`** is an array of `Error` objects describing problems
  67. that occurred during the api call. If everything goes well, this
  68. is zero length.
  69. **`data`** will eventually hold the data returned by your
  70. callback. It's initially `undefined`.
  71. **`status`** tells you what status the fetch is in, out of four
  72. possible values. They're selected from the `FetchStatus` enum.
  73. The diagram below shows the four statuses and how your hook
  74. instance can move between them. You should use the value of
  75. `status` to decide what gets rendered. When `status` equals
  76. `FetchStatus.DATA`, then the data has arrived and you can show
  77. it to the user.
  78. ```
  79. Status Diagram
  80. .--------------------.
  81. | ERROR |
  82. '--------------------'
  83. A | deps change OR
  84. | | user refresh
  85. net fail | |
  86. OR bad data | V
  87. .------------------------------------.
  88. | FETCHING |
  89. '------------------------------------'
  90. A A | good data
  91. | | | arrives
  92. deps | deps change OR | |
  93. resolve | user refresh | V
  94. .--------------. .--------------------.
  95. | init | | HAS DATA |
  96. '--------------' '--------------------'
  97. ```
  98. Note that it's possible to be in the error state and still have
  99. data returned (for example, it's possible to add an error from
  100. your callback, but still return data); in that case, the state
  101. will be ERROR but your data variable will still include data
  102. returned by your callback.
  103. ## Use other vars in API calls
  104. You can include other variables, e.g. useState() vars you're
  105. tracking, directly in your API callback. This adds a dependency
  106. to the hook, so you will also have to give the hook a dependency
  107. array as a fourth argument.
  108. ```
  109. const [hpMovieId, setHpMovieId] = getState(1)
  110. // ...
  111. const [errors, hpTitle, status] = useApiFetch(
  112. 'hp-movies', '1',
  113. (api) => api.getMovieInfo('title', hpMovieId),
  114. [hpMovieId]
  115. )
  116. ```
  117. Here the `getMovieInfo()` api call needs the field to get
  118. (`'title'`), and the movie id (initially `1`). So after the api
  119. returns, we can expect `data` to equal `"Harry Potter and the
  120. Philosopher's Stone"`.
  121. Since you included `hpMovieId` in the dependency array, if that
  122. variable changes, the hook will automatically re-run your API
  123. fetch.
  124. ## Don't fetch until everything's ready
  125. If you need to hold off on your fetch until conditions are right,
  126. you can supply a function to say when you're ready to go. To do
  127. this provide an object as the fifth argument, with a `ready` key
  128. that's your function. Return false from this function if you
  129. don't want to hit the API yet.
  130. E.g. if you start a user's movie selection as null, you will want
  131. the user to choose a movie, _then_ you can get the title.
  132. ```
  133. const [hpMovieId, setHpMovieId] = getState(null)
  134. // ...
  135. const [errors, hpTitle, status] = useApiFetch(
  136. 'hp-movies', '1',
  137. (api) => api.getMovieInfo('title', hpMovieId),
  138. [hpMovieId],
  139. {
  140. ready: () => { hpMovieId !== null }
  141. }
  142. )
  143. ```
  144. Now, no api call happens until you set the `hpMovieId` to
  145. something other than null -- and then, the hook notices the
  146. change and automatically kicks off the fetch. Note that any
  147. variable you refer to inside your `ready` function must be
  148. listed in your dependency array, so the hook knows when to check
  149. again.
  150. ## Other things you get when API-ing
  151. Your API callback receives an API Interface instance as its
  152. first argument; you can accept a second argument to get access
  153. to some other stuff.
  154. Also note that here, since I'm using `await` in my callback, I
  155. need to declare it as an `async` function.
  156. ```
  157. const [errors, hpTitle, status] = useApiFetch(
  158. 'hp-movies', '1',
  159. async (api, controls) => {
  160. // controls.logId is a fresh UUID you should use in your
  161. // `X-JCU-Log-Id` header.
  162. const myLogId = controls.logId
  163. const result = await api.getMovieInfo('title', hpMovieId, myLogId)
  164. // controls.addError(err) is how you signal that there
  165. // was a problem. Give it a string or an `Error` if have
  166. // one, but anything `.toString()`-ible is okay.
  167. if (result.serverProblemReport) {
  168. controls.addError(data.serverProblemReport)
  169. } else {
  170. return result
  171. }
  172. }
  173. [hpMovieId]
  174. )
  175. ```
  176. ## Force a refresh
  177. You hardly ever need to manually invoke an API refresh; React's
  178. ability to detect dependency changes means things will refresh
  179. whenever they need to. Sometimes though you know that server
  180. data has changed, and want to imperatively invoke a new data
  181. fetch.
  182. To do that, add a fourth element to your array of `const`s:
  183. ```
  184. const [errors, hpTitle, status, refresh] = useApiFetch(
  185. ```
  186. ..then later, you can invoke `refresh()` to force your api fetch
  187. to happen. The hook still respects your `ready` function, so
  188. if your refresh isn't happening, check that you aren't passing
  189. in a `ready` that returns false.
  190. @category Hooks
  191. */
  192. export function useApiFetch<T extends ApiInterface>(
  193. apiName: string,
  194. apiVersion: string,
  195. callApi: (api:T, controls:ApiFetchControls) => any,
  196. dependencies: Array<any> = [],
  197. options: ApiFetchOptions = {}
  198. ):[Error[], any, FetchStatus, NullFunction] {
  199. // list of errors; returned direct to the user
  200. const [errors, setErrors] = useState<Error[]>([])
  201. // data, presumeably from the api; returned direct to the user
  202. const [data, setData] = useState<any>(undefined)
  203. // user's deps that were used to get whatever is in `data`,
  204. // stored as a JSON string. Used to track whether they've changed or not
  205. const [dataDeps, setDataDeps] = useState<string>(null)
  206. // have we done our first run? used to calculate status
  207. const [preFirstRun, setPreFirstRun] = useState<boolean>(true)
  208. // are we currently waiting on a user's loading callback? used by status
  209. const [loading, setLoading] = useState<boolean>(false)
  210. // the logId that should be used for all API interactions
  211. const [logId, setLogId] = useState<string>(NilUuid)
  212. // a way to force a re-run of the fetch; given to user because we trust them not to abuse it
  213. const refresh:NullFunction = ()=>{ setDataDeps(null) }
  214. // user's ready func (if not provided, we use a func that returns true)
  215. const ready = options.ready || ( () => true )
  216. // get the api they asked for
  217. const api = useApi<T>(apiName, apiVersion)
  218. function addError(newError: any):void {
  219. if (!(newError instanceof Error)) {
  220. // if the thing thrown isn't an error, make it into one
  221. newError = Error('' + newError)
  222. }
  223. setErrors( (original) => [...original, newError])
  224. }
  225. useEffect( ()=> {
  226. // CAN we get data?
  227. // we can if we have the api, and we're not already getting data,
  228. // and the user's supplied ready function returns true
  229. if (api && !loading && ready()) {
  230. // SHOULD we start getting data?
  231. // get data if we haven't yet, OR the user's deps have changed
  232. // since the last fetch.
  233. // (dataDeps defaults to null, so comparing it to the user's
  234. // supplied deps -- default [] -- will run this for new deps
  235. // and also catch the first run)
  236. if (!depsMatch(dependencies, dataDeps)) {
  237. setLoading(true) // we're loading starting now
  238. setLogId(Uuid1())
  239. setPreFirstRun(false)
  240. // removing this line would mean any "old" data stays while the refresh happens
  241. setData(undefined) // blank out the data while we're refreshing it
  242. // doing this now means a refresh() call during the fetch will force another
  243. // fetch once it completes. If we did this after awaiting the user's function,
  244. // a refresh() during a fetch would NOT cause a second load.
  245. setDataDeps(serialise(dependencies)) // remember the user deps we're using on this fetch
  246. ;(async ()=> { // run the rest of the fetch in the background
  247. setErrors([]) // start with no errors
  248. try {
  249. // call the user's handler, awaiting the result
  250. const userData = await callApi(api, {addError, logId})
  251. setData(userData) // whatever the user returned, put it in data
  252. } catch (e) {
  253. // if there was an exception thrown:
  254. setData(undefined)
  255. addError(e)
  256. } finally {
  257. setLoading(false) // loading indicator off
  258. }
  259. })() // ...immediately invoke this anonymous async function
  260. }
  261. }
  262. // our deps are our own stuff, plus the user's dependencies
  263. }, [data, loading, api, callApi, dataDeps, ready, ...dependencies])
  264. // finally, we can hand back the errors, data, loading state, and the run-again function
  265. let status:FetchStatus = FetchStatus.INIT
  266. if (preFirstRun) { status = FetchStatus.INIT }
  267. else if (loading) { status = FetchStatus.FETCH }
  268. else if (errors.length > 0) { status = FetchStatus.ERROR }
  269. else { status = FetchStatus.DATA }
  270. return [errors, data, status, refresh]
  271. }
  272. // -------------------------------------------------------------
  273. function depsMatch(deps1: Array<any> | string, deps2: Array<any> | string): boolean {
  274. // Compare the arrays. true/false for same/different.
  275. return (serialise(deps1) === serialise(deps2))
  276. }
  277. // -------------------------------------------------------------
  278. function serialise(dependency: Array<any> | string): string {
  279. if (typeof dependency === 'string') {
  280. return dependency
  281. } else {
  282. return JSON.stringify(dependency)
  283. }
  284. }