-
Notifications
You must be signed in to change notification settings - Fork 95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(useMemoCache): useMemo with cache #1063
base: master
Are you sure you want to change the base?
feat(useMemoCache): useMemo with cache #1063
Conversation
Codecov Report
@@ Coverage Diff @@
## master #1063 +/- ##
==========================================
- Coverage 98.52% 98.50% -0.02%
==========================================
Files 63 65 +2
Lines 1082 1135 +53
Branches 180 190 +10
==========================================
+ Hits 1066 1118 +52
Misses 2 2
- Partials 14 15 +1
📣 We’re building smart automated test selection to slash your CI/CD build times. Learn more |
Please use the provided PR template. |
And from the start about implementation - this implementation has several major issues
|
Thanks for the PR! I will have time to review this over the weekend. Someone might also review it before me. For now, could you expand a little on the valid use-case for this hook? What is the benefit over |
I really want to have a discussion on the use-cases of this hook before reviewing this (and before any more code is written) to make sure that no unnecessary work is done by anyone. At the moment, it is not clear to me what the use-case for this hook is. |
The usecase is pretty simple - there are no unncessary invocations of memo factory in case provided deps are the same. 4ex. in case we had as dep value 2 then 3 then 2 again - this hook will fast-return result of invocation when dep was 2 without calling it again so it is like good-old memoization |
I see, thanks. So this would be good if one was doing something really heavy in the factory function. |
@michaltarasiuk you havent resolved issues - you just accepted all your's - there were changes to import paths and output bundle - your PR now will roll back all changes we've made🙃 Reread contribution guide please and appropriately rebase your changes. |
@michaltarasiuk Could you notify us when this is ready for review or is it already? |
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You've done a thorough job! I noticed some things that need to be fixed and @xobotyi might find some more. Anyhow, I'm sure this will be merged eventually.
/* eslint-disable react-hooks/exhaustive-deps */ | ||
import React, { useMemo, useRef, useState } from 'react'; | ||
import { useMemoCache } from '../..'; | ||
|
||
const dependencyListMapper = { | ||
true: [1, 3], | ||
false: [2, 4], | ||
}; | ||
|
||
const getSummary = (numbers: number[]) => numbers.reduce((a, b) => a + b); | ||
|
||
const boolToString = (bool: boolean) => String(bool) as 'true' | 'false'; | ||
|
||
export const Example: React.FC = () => { | ||
const isTruthy = useRef(false); | ||
|
||
const [dependencyList, setDependencyList] = useState( | ||
() => dependencyListMapper[boolToString(isTruthy.current)] | ||
); | ||
|
||
const memoSpy = useRef(0); | ||
const memoCacheSpy = useRef(0); | ||
|
||
const memo = useMemo(() => { | ||
memoSpy.current++; | ||
|
||
return getSummary(dependencyList); | ||
}, dependencyList); | ||
|
||
const memoCache = useMemoCache(() => { | ||
memoCacheSpy.current++; | ||
|
||
return getSummary(dependencyList); | ||
}, dependencyList); | ||
|
||
const toggleDependencyList = () => { | ||
isTruthy.current = !isTruthy.current; | ||
|
||
setDependencyList(dependencyListMapper[boolToString(isTruthy.current)]); | ||
}; | ||
|
||
return ( | ||
<div> | ||
<section> | ||
<h1>Memo</h1> | ||
<p>summary: {memo}</p> | ||
<p>calls: {memoSpy.current}</p> | ||
</section> | ||
<section> | ||
<h1>Memo with cache</h1> | ||
<p>summary: {memoCache}</p> | ||
<p>calls: {memoCacheSpy.current}</p> | ||
</section> | ||
<button onClick={toggleDependencyList}>toggle dependency list</button> | ||
</div> | ||
); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The example is good, but I think the summary
value just being toggled between 4 and 6 is really confusing. I think the example needs to do something a bit more realistic to really illustrate the functionality of the hook. Maybe you could do something like let the user input a number and then count all Fibonacci numbers until that point while displaying the number of calls made to the factory functions below.
Also, make sure that the example is understandable by using the example program and reading the example code. Storybook doesn't show all code that you have in the example.stories.tsx
file. For example, now getSummary
and dependencyListMapper
are not visible in the example code, which prevented me from understanding the example when reading through Storybook.
Lastly, getSummary
should be renamed to sum
, if you intend to keep it.
<Meta title="Miscellaneous/useMemoCache" component={Example} /> | ||
|
||
# useMemoCache | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hook description missing here. You have it in other places and it's good.
src/useMemoCache/__docs__/story.mdx
Outdated
function useMemoCache<State, Deps extends DependencyList>(factory: () => State, deps: Deps): State; | ||
``` | ||
|
||
#### Importing | ||
|
||
<ImportPath /> | ||
|
||
#### Arguments | ||
|
||
- **factory** _`() => unknown`_ - useMemo factory function. | ||
- **deps** _`DependencyList`_ - useMemo dependency list. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function reference and the arguments description are missing the customAreHookInputsEqual
argument.
src/useMemoCache/__tests__/dom.ts
Outdated
expect(spy).toHaveBeenCalledTimes(2); | ||
}); | ||
|
||
it('should handle unstable refference of `areHookInputsEqual`', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
reference
src/useMemoCache/index.ts
Outdated
import { useMemo } from 'react'; | ||
|
||
import type { DependencyList } from 'react'; | ||
import { areHookInputsEqual as nativeAreHookInputsEqual } from '../util/areHookInputsEqual'; | ||
import { useSyncedRef } from '../useSyncedRef'; | ||
|
||
// eslint-disable-next-line symbol-description | ||
const none = Symbol(); | ||
|
||
type None = typeof none; | ||
type CachedItem<State> = { state: State; dependencyList: DependencyList }; | ||
|
||
export const createMemoCache = <State>( | ||
customAreHookInputsEqual?: typeof nativeAreHookInputsEqual | ||
) => { | ||
const cache = new Map<string, Set<CachedItem<State>>>(); | ||
const areHookInputsEqual = customAreHookInputsEqual ?? nativeAreHookInputsEqual; | ||
|
||
const get = (dependencyList: DependencyList) => { | ||
const key = String(dependencyList); | ||
const cached = cache.get(key); | ||
|
||
if (!cached) { | ||
return none; | ||
} | ||
|
||
const cachedItem = [...cached.values()].find((item) => | ||
areHookInputsEqual(item.dependencyList, dependencyList) | ||
); | ||
|
||
if (cachedItem) { | ||
return cachedItem.state; | ||
} | ||
|
||
return none; | ||
}; | ||
|
||
const set = (dependencyList: DependencyList, state: State) => { | ||
const key = String(dependencyList); | ||
|
||
const hasCachedItem = cache.has(key); | ||
|
||
if (!hasCachedItem) { | ||
cache.set(key, new Set()); | ||
} | ||
|
||
const cachedItem = cache.get(key); | ||
const canAddToItem = | ||
cachedItem && | ||
![...cachedItem.values()].some((item) => | ||
areHookInputsEqual(item.dependencyList, dependencyList) | ||
); | ||
|
||
if (canAddToItem) { | ||
cachedItem.add({ dependencyList, state }); | ||
} | ||
}; | ||
|
||
const isNone = (state: None | State): state is None => state === none; | ||
|
||
return { | ||
get, | ||
set, | ||
isNone, | ||
}; | ||
}; | ||
|
||
/** | ||
* Like useMemo but with cache based on dependency list. | ||
*/ | ||
export const useMemoCache = <State>( | ||
factory: () => State, | ||
deps: DependencyList, | ||
customAreHookInputsEqual?: typeof nativeAreHookInputsEqual | ||
): State => { | ||
const syncedCustomAreHookInputsEqual = useSyncedRef(customAreHookInputsEqual); | ||
const memoCache = useMemo( | ||
() => createMemoCache<State>(syncedCustomAreHookInputsEqual.current), | ||
[syncedCustomAreHookInputsEqual] | ||
); | ||
|
||
const memo = useMemo(() => { | ||
const cachedState = memoCache.get(deps); | ||
|
||
if (!memoCache.isNone(cachedState)) { | ||
return cachedState; | ||
} | ||
|
||
const state = factory(); | ||
|
||
memoCache.set(deps, state); | ||
|
||
return state; | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
}, deps); | ||
|
||
return memo; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see anywhere the part which would prevent the cache from becoming infinitely large. As requested earlier, you should add such a limiter, which would allow, say, 64 entries, and write tests for it.
Looking at the code quickly in the browser, I think you can just not allow the if (!hasCachedItem && cacheHasSpaceLeft) {
...
} |
@@ -14,7 +14,10 @@ type CachedItem<State> = { state: State; dependencyList: DependencyList }; | |||
export const createMemoCache = <State>( | |||
customAreHookInputsEqual?: typeof nativeAreHookInputsEqual | |||
) => { | |||
const cache = new Set<CachedItem<State>>(); | |||
const MAX_ENTRIES = 64; | |||
let indexCounter = 0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can't you just check cache.size
to determine the current size of the cache? This counter seems unnecessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hello, indexCounter
is used to determine which element should be removed when threshold is hit
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, I see. Now I wonder if it would be better to just stop accepting new items to the cache after the maximum size is reached since the cache items are basically equally "important" so there is no reason to be deleting one item instead of another when the cache is filled.
Changed to use |
This brings the time complexity of accessing the cache back to O(n), which is not ideal. |
Previously i haven't used advantages that |
At this point, we're just waiting for what @xobotyi has to say. He's been busy lately, so let's be patient! |
Hello @xobotyi, can you review pull request? |
Hook description
Like useMemo but with cache based on dependency list.
Valid use-case for the hook
Avoid expensive callculation with the same dependencies
Checklist
react-use
, have you checked Port remaining hooks fromreact-use
#33 and the migration guideto confirm that the hook has been approved for porting?