|
| 1 | +# `useLazyLoadData` |
| 2 | + |
| 3 | +_A React hook that optimizes promisable functions while abstracting away complexity with built-in lazy loading, caching, and dependency resolution, without needing to be invoked._ |
| 4 | + |
| 5 | +## Rationale |
| 6 | +In larger applications, there are often core pieces of data required to render different aspects and features. Suppose, for instance, you are building a shopping application that has _wish list_ and _recommended items_ pages. Chances are, those pages would make _user wish list_ and _recommendations_ calls, respectively. However, in this example, both of these calls (and others) require the response of a _shopping profile_ call. |
| 7 | + |
| 8 | +A naive implimentation may be: |
| 9 | + |
| 10 | +```ts |
| 11 | +import React from 'react'; |
| 12 | +import {useLoadData} from '@optum/react-hooks'; |
| 13 | + |
| 14 | +// Wish List page |
| 15 | +export const WishList = (props) => { |
| 16 | + const loadedShoppingProfile = useLoadData(fetchShoppingProfile); |
| 17 | + const loadedWishList = useLoadData((shoppingProfile) => { |
| 18 | + return fetchWishList(shoppingProfile) |
| 19 | + }, [loadedShoppingProfile]); |
| 20 | + |
| 21 | + ... |
| 22 | +} |
| 23 | + |
| 24 | +// Shopping Recommendations page |
| 25 | +export const ShoppingRecommendations = (props) => { |
| 26 | + const loadedShoppingProfile = useLoadData(fetchShoppingProfile); |
| 27 | + const loadedRecommendations = useLoadData((shoppingProfile) => { |
| 28 | + return fetchRecommendations(shoppingProfile) |
| 29 | + }, [loadedShoppingProfile]); |
| 30 | + |
| 31 | + ... |
| 32 | +} |
| 33 | +``` |
| 34 | + |
| 35 | +While this code would function, it would not be optimized. If a user were to navigate back and forth between the _wish list_ and _recommendations_ pages, we would notice `fetchShoppingProfile` invoked each and every time. This could be improved by leveraging the callbacks and intitial data arguments of `useLoadData` to cache and reuse responses. However, doing so relies on the developer remembering to implement/intregrate with the application's cache in addition to having a cohesive and coordinated cache already in place. |
| 36 | + |
| 37 | +A slightly more fool-proof and optimal way of handling this would be instantiating `loadedShoppingProfile` into a top level context available in both our pages: |
| 38 | + |
| 39 | +```ts |
| 40 | +// Shopping Provider at root of application |
| 41 | +export const ShoppingProvider = (props) => { |
| 42 | + const loadedShoppingProfile = useLoadData(fetchShoppingProfile); |
| 43 | + return ( |
| 44 | + <ShoppingContext.Provider value={{loadedShoppingProfile}}> |
| 45 | + {children} |
| 46 | + </ShoppingContext.Provider> |
| 47 | + ); |
| 48 | +} |
| 49 | + |
| 50 | +// Wish List page |
| 51 | +export const WishList = (props) => { |
| 52 | + const {loadedShoppingProfile} = useShoppingContext(); |
| 53 | + const loadedWishList = useLoadData((shoppingProfile) => { |
| 54 | + return fetchWishList(shoppingProfile) |
| 55 | + }, [loadedShoppingProfile]); |
| 56 | + |
| 57 | + ... |
| 58 | +} |
| 59 | + |
| 60 | +// Shopping Recommendations page |
| 61 | +export const ShoppingRecommendations = (props) => { |
| 62 | + const {loadedShoppingProfile} = useShoppingContext(); |
| 63 | + const loadedRecommendations = useLoadData((shoppingProfile) => { |
| 64 | + return fetchRecommendations(shoppingProfile) |
| 65 | + }, [loadedShoppingProfile]); |
| 66 | + |
| 67 | + ... |
| 68 | +} |
| 69 | +``` |
| 70 | + |
| 71 | +Moving `loadedShoppingProfile` into a context eliminates the risk of fetching the _shopping profile_ more than once, while removing cognitive overhead for caching. However,`fetchShoppingProfile` will now _always_ be invoked, no matter which page the user lands on. For example, if the application's landing page does not require _shopping profile_, we would be needlessly fetching this data. While an argument could be made that this pattern "prefetches" data for other pages that require it, there is no guarantee a user would ever land on those pages, making this a needlessly costly operation for both front and backend. |
| 72 | + |
| 73 | +**Enter `useLazyLoadData`:** |
| 74 | +This is where `useLazyLoadData` truly shines - it simultaneously improves perfomance with behind-the-scene caching and spares developers of that cognitive overhead, all while guaranteeing calls are only made _on demand_. |
| 75 | + |
| 76 | + |
| 77 | +```ts |
| 78 | +// Shopping Provider at root of application |
| 79 | +export const ShoppingProvider = (props) => { |
| 80 | + const lazyFetchShoppingProfile = useLazyLoadData(fetchShoppingProfile); |
| 81 | + return ( |
| 82 | + <ShoppingContext.Provider value={{lazyFetchShoppingProfile}}> |
| 83 | + {children} |
| 84 | + </ShoppingContext.Provider> |
| 85 | + ); |
| 86 | +} |
| 87 | + |
| 88 | +// Wish List page |
| 89 | +export const WishList = (props) => { |
| 90 | + const {lazyFetchShoppingProfile} = useShoppingContext(); |
| 91 | + const loadedShoppingProfile = useLoadData(lazyFetchShoppingProfile); |
| 92 | + const loadedWishList = useLoadData((shoppingProfile) => { |
| 93 | + return fetchWishList(shoppingProfile) |
| 94 | + }, [loadedShoppingProfile]); |
| 95 | + |
| 96 | + ... |
| 97 | +} |
| 98 | + |
| 99 | +// Shopping Recommendations page |
| 100 | +export const ShoppingRecommendations = (props) => { |
| 101 | + const {lazyFetchShoppingProfile} = useShoppingContext(); |
| 102 | + const loadedShoppingProfile = useLoadData(lazyFetchShoppingProfile); |
| 103 | + const loadedRecommendations = useLoadData((shoppingProfile) => { |
| 104 | + return fetchRecommendations(shoppingProfile) |
| 105 | + }, [loadedShoppingProfile]); |
| 106 | + |
| 107 | + ... |
| 108 | +} |
| 109 | +``` |
| 110 | +With this change, `fetchShoppingProfile` does not get invoked until the user lands on a page that requires it. Additionally, any subsequent page that invokes `lazyFetchShoppingProfile` will directly received the cached result (which is **not** a promise!) rather than making a new shopping profile call. |
| 111 | + |
| 112 | +--- |
| 113 | + |
| 114 | +## Usage |
| 115 | +`useLazyLoadData` supports two overloads. Each overload handles a specific usecase, and will be covered seperately in this documentation: |
| 116 | +### Overload 1: Basic usage |
| 117 | +At bare minimum, `useLazyLoadData` takes in a promisable function, _fetchData_, as it's base parameter, and returns a wrapped version of this function. Call this _lazyFetchData_ for this exmaple. No matter how many quick successive calles to `lazyFetchData` are made, _fetchData_ only is invoked **once**: |
| 118 | + |
| 119 | +```ts |
| 120 | +const lazyFetchData = useLazyLoadData(fetchData); |
| 121 | + |
| 122 | +const promise1 = lazyFetchData(); // intitializes a promise |
| 123 | +const promise2 = lazyFetchData(); // reuses the same promise intialized in above line |
| 124 | + |
| 125 | +const [data1, data2] = await Promise.all([promise1, promise2]); |
| 126 | + |
| 127 | +const data3 = lazyFetchData(); // data will not be a promise, since the shared promise resolved and the now-cached returned instead! |
| 128 | +``` |
| 129 | + |
| 130 | +#### Overriding Cache |
| 131 | +By default, `useLazyLoadData` will try to either return cached data where available, or reuse a promise if one is currently active. However, sometimes it is necessary to be able to fetch fresh data. Here's how: |
| 132 | + |
| 133 | +```ts |
| 134 | +const lazyFetchData = useLazyLoadData(fetchData); |
| 135 | + |
| 136 | + |
| 137 | +// lazyFetchData will override cache when passed true |
| 138 | +const freshData = await lazyFetchData(true); |
| 139 | +``` |
| 140 | + |
| 141 | +#### Dependencies |
| 142 | +Another strength of `useLazyLoadData` is it's dependency management. Similar to `useLoadData`, the hook takes an array of dependencies. Typically, these dependencies would be partial calls to other promisable functions (for instance, other functions given by `useLazyLoadData`). These dependencies, once resolved, are injected into the _fetchData_ function you passed as argument: |
| 143 | + |
| 144 | +```ts |
| 145 | +const lazyFetchDependency = useLazyLoadData(fetchDependency); |
| 146 | +const lazyFetchData = useLazyLoadData(([dep]) => { //dep will be the awaited return type of fetchDependancy |
| 147 | + return fetchData(dep) |
| 148 | +}, [lazyFetchDependency]); |
| 149 | +const lazyFetchResult = useLazyLoadData(([dep, data]) => { |
| 150 | + return fetchResult(dep, data) |
| 151 | +}, [lazyFetchDependency, lazyFetchData]); |
| 152 | +``` |
| 153 | +In this example, both `fetchDependency` and `fetchData` will only be invoked once if `lazyFetchResult` is invoked. Notice how `useLazyLoadData` cleans up what could otherwise turn into messy _await_ statements, and effectively abstracts away complexity involved with making the actual call with how it handles dependencies. |
| 154 | + |
| 155 | + |
| 156 | +#### Initial Data |
| 157 | +Should you already have initial data (either from a cache or SSR), you can pass it into `useLazyLoadData`: |
| 158 | + |
| 159 | + |
| 160 | +```ts |
| 161 | +const lazyFetchNames = useLazyLoadData(fetchNumbers, [], [1,2,3,4]); |
| 162 | + |
| 163 | +lazyFetchNames(); // will return [1,2,3,4] |
| 164 | + |
| 165 | +lazyFetchNames(true); // will override cache and invoke fetchNumbers |
| 166 | + |
| 167 | +``` |
| 168 | + |
| 169 | +#### Callback |
| 170 | +`useLazyLoadData` allows a callback to be passed. This function will only be invoked if the underlying _fetchData_ function passed successfully resolves, or when initial data is returned: |
| 171 | + |
| 172 | +```ts |
| 173 | +const lazyFetchData = useLazyLoadData(fetchData, [], undefined, (res) => setCache(res)); |
| 174 | + |
| 175 | +``` |
| 176 | + |
| 177 | +### Overload 2: With Arguments |
| 178 | +There may be times where you want to be able to pass arguments into the function `useLazyLoadData` exposes (apart from overriding cache). This can be achieved doing the following: |
| 179 | + |
| 180 | + |
| 181 | +```ts |
| 182 | +const lazyFetchDependency = useLazyLoadData(fetchDependency); |
| 183 | + |
| 184 | +/* |
| 185 | +In this example, lazyFetchData accepts arg1 and arg2. |
| 186 | +Arguments are passed after the resolved dependency array |
| 187 | +*/ |
| 188 | +const lazyFetchData = useLazyLoadData(([dep], arg1: string, arg2: number) => { |
| 189 | + const something = doSomething(arg1, arg2) |
| 190 | + return fetchData(dep, something) |
| 191 | +}, |
| 192 | +(arg1, arg2) => arg1, // to be discussed in the next section |
| 193 | +[lazyFetchDependency] // dependencies still work as before |
| 194 | +); |
| 195 | + |
| 196 | +lazyFetchData(false, 'argument', 2) // overrideCache always remains the first argument |
| 197 | +``` |
| 198 | + |
| 199 | +#### Caching by arguments |
| 200 | +When enabling arguments for the function `useLazyLoadData` exposes, useLazyLoadData assumes different arguments may yield different results. For this reason, useLazyLoadData will map promises and results (once available) to a serialized version of the arguments passed. As soon as your _fetchData_ function accepts arguments (apart from dependencies), `useLazyLoadData` requires passing a function telling it _how_ to cache with those args: |
| 201 | + |
| 202 | +```ts |
| 203 | +const lazyFetchDependency = useLazyLoadData(fetchDependency); |
| 204 | + |
| 205 | +const lazyFetchData = useLazyLoadData(([dep], arg1: string, arg2: SomeObject) => { |
| 206 | + const something = doSomething(arg1, arg2); |
| 207 | + return fetchData(dep, something); |
| 208 | + }, [lazyFetchDependency], undefined, undefined, |
| 209 | + /* |
| 210 | + arg1 and arg2 are the same as above. |
| 211 | + In this example, we only wish to cache by a property of arg2 |
| 212 | + */ |
| 213 | + (arg1, arg2) => (arg2.identifier)); |
| 214 | +``` |
| 215 | + |
| 216 | +#### Dependencies |
| 217 | +Dependendies work the same in this overload as in the previous one. See above example for argument positioning. |
| 218 | + |
| 219 | +#### Initial Data |
| 220 | +This overload still allows passing _initial data_. However, in this mode, responses are cached by arguments passed (see previous section). Therefore, only passing a response will not be very meaningful. For this reason, this overload's type for _initial data_ is a key-value mapping of responses, where each key is the serialized version of arguments expected to yield the value: |
| 221 | + |
| 222 | +```ts |
| 223 | +const lazyFetchAge = useLazyLoadData(async ([], firstName: string, lastName: string) => { |
| 224 | + return await fetchAge(firstName, lastName) |
| 225 | +}, |
| 226 | +(firstName, lastName) => ([firstName, lastName].join('-')), |
| 227 | +[], |
| 228 | +{ |
| 229 | + 'John-Smith': 24, |
| 230 | + 'Sarah-Jane': 42, |
| 231 | + 'Joe-Jonson': 13 |
| 232 | +}); |
| 233 | + |
| 234 | +lazyFetchAge(false, 'John', 'Smith') // fetchAge will not be invoked, as it maps to a provided initial data field |
| 235 | +lazyFetchAge(false, 'Sarah', 'Smith') // fetchAge will be invoked, since initial data did not contain key for 'Sarah-Smith' |
| 236 | +lazyFetchAge(true, 'John', 'Smith') // fetchAge will be invoked, since cache is overriden |
| 237 | +``` |
| 238 | + |
| 239 | +#### Callback |
| 240 | +The callback in this overload only varies slightly from the other overload. |
| 241 | +In addition to the callback function being passed the result, it will also receive the original arguments used to retrieve said result for convenience: |
| 242 | + |
| 243 | +```ts |
| 244 | +const lazyFetchAge = useLazyLoadData(async ([], firstName: string, lastName: string) => { |
| 245 | + return await fetchAge(firstName, lastName) |
| 246 | +}, |
| 247 | +(firstName, lastName) => ([firstName, lastName].join('-')), |
| 248 | +[], |
| 249 | +undefined, |
| 250 | +(res, firstName, lastName) => { |
| 251 | + console.log(`The age of ${firstName} ${lastName} is ${res}`) |
| 252 | +}); |
| 253 | +``` |
| 254 | + |
| 255 | +## API |
| 256 | +`useLazyLoadData` takes the following arguments: |
| 257 | + |
| 258 | +### Arguments |
| 259 | +#### Overload 1: Basic Usage |
| 260 | + |
| 261 | +| Name | Type | Description | |
| 262 | +|-|-|-| |
| 263 | +| `fetchData` | `([...AwaitedReturnType<deps>]) => Promisable<T>` | The function to be invoked with resolved dependencies| |
| 264 | +| `deps` | `[...deps]` _(optional)_ | Dependencies to be invoked and/or resolved before injecting into and invoking `fetchData`. These may typically be other instances of `useLazyLoadData`. | |
| 265 | +| `initialData` | `T` _(optional)_ | If passed, function will return initial data when no additional arguments are passed. | |
| 266 | +| `callback` | `(data: T) => void` _(optional)_ | Gets invoked with the result of `fetchData` after resolving. | |
| 267 | + |
| 268 | + |
| 269 | + |
| 270 | +#### Overload 2: With arguments |
| 271 | + |
| 272 | +| Name | Type | Description | |
| 273 | +|-|-|-| |
| 274 | +| `fetchData` | `([...deps], ...args) => Promisable<T>`| The function to be invoked with dependencies and arguments| |
| 275 | +| `getCacheKey` | `(...args) => string` | Function declaring how promises/results are mapped by arguments passed into returned function. | |
| 276 | +| `deps` | `[...deps]` _(optional)_ | Dependencies to be invoked and/or resolved before injecting into and invoking `fetchData`. These may typically be other instances of `useLazyLoadData`. | |
| 277 | +| `initialData` | `Record<string, T>` _(optional)_ | If passed, function will return initial data when no additional arguments are passed. | |
| 278 | +| `callback` | `(data: T, ...args) => void` _(optional)_ | Gets invoked with the result of `fetchData` after resolving. | |
| 279 | + |
| 280 | +### Return Type |
| 281 | +The return value of `useLazyLoadData` is `(overrideCache?: boolean, ...args: Args) => Promisable<T>`, where `T` is the type of the result of `fetchData` (`Args` will be `Never` over Overload 1) |
| 282 | + |
| 283 | + |
0 commit comments