Skip to content

Commit 8d683b3

Browse files
feat: useLazyLoadData (#43)
* feat: useLazyLoadData * useLazyLoadData test cases * documentation for useLazyLoadData * exports * format
1 parent c741dc0 commit 8d683b3

File tree

6 files changed

+632
-1
lines changed

6 files changed

+632
-1
lines changed

hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from './useFocus';
44
export * from './useRetry';
55
export * from './useServiceEffect';
66
export * from './useOptionalDependency';
7+
export * from './useLazyLoadData';

hooks/useLazyLoadData/README.md

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
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+

hooks/useLazyLoadData/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useLazyLoadData';

0 commit comments

Comments
 (0)