-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
[Feature request] Customizable control over when a selector triggers re-render #1416
Comments
This is an urgently needed feature. |
In the example, any reason why you can't use three separate |
Not who you were asking, but I have an example that involves manipulating Points ( It also wouldn't help with the issue where selectors trigger rerenders even when there are no updates because the derived state is always a new object. export const viewportMousePositionState = atom<Point>({
key: 'viewportMousePosition',
default: ZERO_POINT
})
export const gridMousePositionState = selector<Point>({
key: 'gridMousePosition',
get: ({ get }) => {
const gridViewportPosition = get(gridViewportPositionState)
const viewportMousePosition = get(viewportMousePositionState)
const gridOffset = get(gridOffsetState)
const zoomLevel = get(zoomLevelState)
return {
x: ((viewportMousePosition.x - gridViewportPosition.x) / zoomLevel) - gridOffset.x,
y: ((viewportMousePosition.y - gridViewportPosition.y) / zoomLevel) - gridOffset.y
}
},
cachePolicy_UNSTABLE: {
eviction: 'most-recent'
}
})
export const gridSnappedMousePositionState = selector<Point>({
key: 'gridSnappedMousePosition',
get: ({ get }) => {
const mousePosition = get(gridMousePositionState)
return {
x: Math.round(mousePosition.x),
y: Math.round(mousePosition.y)
}
},
cachePolicy_UNSTABLE: {
eviction: 'most-recent'
}
}) In this example any component that consumes |
I think this has been a long-standing issue with Recoil #314 and solving it will make this the go-to state management system for React. |
As was mentioned, this can currently be addressed via intermediate selectors from #314. Though, it makes sense to improve the API for this without intermediate selectors. API options to consider:
|
@drarmstr If you're taking votes I'd go with option 1, but applied to both atoms and selectors. Seems like it would scale the best. Any atom/selector that has lots of dependents or changes frequently can just have an Ex: const viewportMousePositionState = atom<Point>({
key: 'viewportMousePosition',
default: ZERO_POINT,
isEqual: arePointsEqual,
})
const gridMousePositionState = selector<Point>({
key: 'gridMousePosition',
get: ({ get }) => { ... },
isEqual: arePointsEqual,
})
function arePointsEqual(a: Point, b: Point): boolean {
return a.x === b.x && a.y === b.y
} I'm not sure how you would implement solution 2 without complicating the selector options. You'd need to pass a deps array like for hooks that specifies the depended on atom/selector, along with the comparator function every time you want to use it. Ex: const gridMousePositionState = selector<Point>({
key: 'gridMousePosition',
get: ({ get }) => {
const gridViewportPosition = get(gridViewportPositionState)
const viewportMousePosition = get(viewportMousePositionState)
const gridOffset = get(gridOffsetState)
const zoomLevel = get(zoomLevelState)
...
},
deps: [
{ state: viewportMousePositionState, isEqual: arePointsEqual },
{ state: gridViewportPositionState, isEqual: arePointsEqual },
// etc
// Seems like a lot more boilerplate, and I'm not sure what benefit it offers.
]
}) Similar thoughts for option 3: more boilerplate, not clear why you would need different comparators at different call sites. Ex: const gridMousePositionState = selector<Point>({
key: 'gridMousePosition',
get: ({ get }) => {
const gridViewportPosition = get(gridViewportPositionState, arePointsEqual)
const viewportMousePosition = get(viewportMousePositionState, arePointsEqual)
const gridOffset = get(gridOffsetState, arePointsEqual)
const zoomLevel = get(zoomLevelState)
...
}
})
function ExampleComponent() {
const gridMousePosition = useRecoilValue(gridMousePositionState, arePointsEqual)
...
} |
If it were me, I'd go for a mix of option 1 and 3. |
@drarmstr I think this aligns with a need we have, so posting here but LMK if this should be a separate request. We (Zapier) have a need to be able to use some information from other selectors/atoms in a selector's A concrete example is that while for an API request made in a selector we need set X, we only want to re-evaluate the selector and as part of that redo the API request when Y⊂X (a subset of X) changes, or when we manually tell it to (using I'd personally lean towards your option three, as in: const oneState = selector({
key: 'one',
get: async ({ get }) => {
// This we need and want to "subscribe" to (register as dependency), so that we re-evaluate when its value changes
const two = get(twoState);
// This we need, but don't want to re-evaluate for
const three = get(threeState, { subscribe: false });
return fetch(url, { data: { two, three } });
}
}); Why I'd prefer this is that to me any call to I realize that for the original request, this still requires an intermediate selector to select either value from On the implementation side I wonder if this is as easy as skipping the following line when
|
@FokkeZB If you just want to read a value of an atom/selector without subscribing to it then you can use |
@drarmstr I'm aware of that, but notice how in my use case I need to read values inside of a selector's Is your suggestion to do something like this perhaps? const oneState = selector({
key: 'one',
get: async ({ get, getCallback }) => {
// This we need and want to "subscribe" to (register as dependency), so that we re-evaluate when its value changes
const two = get(twoState);
// This we need, but don't want to re-evaluate for
const unsubscribedGet = getCallback<Array<RecoilValue<any>>, any>(
({ snapshot }) => async (recoilVal) => snapshot.getPromise(recoilVal)
) as <T>(recoilVal: RecoilValue<T>) => Promise<T>;
const three = await unsubscribedGet(threeState);
return fetch(url, { data: { two, three } });
}
}); That feels hacky to me, and also fails with:
|
As I mentioned, the mix of option 1 and 3 would grant enough flexibility for everyone to be able to solve for their use cases, as well as being being a simple solution that doesn't get in your way, what with it being optional. I haven't read the code for triggering the subscribtion notifications, so I don't know if it's particularly easy to implement though, but I think it's the best solution. |
What I don't like about option 1 is that the puts the comparator at the producer, not the consumers (!) of values. I might have multiple consumers of the same value, and some of them might want to re-evaluate when the consumed value changes, while others might not. |
Yes, but by putting both options in the hands of the developer, with option 3 taking precendence over option 1, you can save a lot of code for the cases where you want the condition to be the same everywhere. You could absolutely achieve the same results of option 1 by using option 3 in all consumers, option 1 just saves you some work, and the worey that if a new dev is attached to the project, they'll forget to set the cache comparing function in the new place where they need to consume the state. I have no evidence for this, but my gut feeling is that the most common use case is the one that option 1 solves, and the one that option 3 solves is more rare. |
About:
In contrast, I worry that when consuming a value I might be puzzled when I notice that my selector doesn't always re-evaluate when I know the consumed value has changed. Then only by looking at the implementation of the consumed value will I find out why. Also technically I think option 3 is much easier to implement, and I don't think there's any use case that option 1 can solve, but option 3 can't. And since AFAIK the goal is to keep the API to a minimum, I think option 3 makes more sense. |
I don't think you (usually) should be think about where a selector gets its data from, but instead just what data it promises to deliver. If someone made a selector with a custom caching logic, it was probably for a good reason, and hopefully it's self apparent from the domain (e.g. a selector for getting the mouse X coordinate) or documented by the creator of the selector.
I think that's probably correct.
This is a good point, and I'll admit that my main motivation for also wanting option 1 (with option 3 overriding) is a combination of laziness on my part, and worry that consumers of my state will forget to use the custom caching logic I wrote, that makes my selector behave as I intented. I'm aware that I can synthetically create option 1 by creating a selector (A) which I don't export, and then create another selector (B) which I do export, which returns the data from A but with a custom caching logic. EDIT: And @drarmstr has your team had a chance to mull over the feedback here since you're initial post about the three options? |
To make sure I understand your original question correctly; what does As I'm reading it this is @drarmstr's option 2, where I already explained why option 1 doesn't appeal to me either because in this case IMO option 3 is the only reasonable option, because here we determine whether a change of value from a dependency should trigger us to re-evaluate right where we create the dependency by calling I also think this should take the form of a complex comparator function, but simply a boolean. If only part of the value we Yes, this might feel boilerplaty, but IMO it keeps us closer to the data-flow graph idea. Think of option 3's boolean as controlling whether a line in the graph should be solid (triggering re-evaluation) or dashed (not triggering re-evaluation). |
In my original post, my example was basically @drarmstr's option 1, where I agree with your critique of option 2, I think it'll be too complex and difficult to control.
Then I think you should have your selector/component depend on EDIT: // state which supplies a curser position
export const curserPosition = atom({
key: 'curserPosition',
default: [0, 0],
// imagine that this effect sets the value when the mouse is moved
// so very often, and in small increments
effects: [updateCurserPositionEffect],
}); Let's assume we want to export similar state that snaps to a grid of size export const curserPositionSnapped = selector({
key: 'curserPositionSnapped',
get: ({ get }) => {
// get the mouse position
const position = get(curserPosition);
// calculate snapping points
return [
Math.round(position[0] / gridSize) * gridSize,
Math.round(position[1] / gridSize) * gridSize,
]
},
// I made up the name 'cacheOptions' because we there are already other options for the cache
cacheOptions: {
// super verbose name which I hope explains my intentions for it
defaultDownstreamTriggerComparer: (old, new) => old[0] !== new[0] || old[1] !== new[1],
}
}); If any consumer of [0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[5, 0],
[5, 0],
[5, 0],
[5, 0],
[5, 5], instead of [0, 0],
[5, 0],
[5, 5], Causing a lot more rerenders than necessary. |
Ah.... I thought #314 had (also) address this example use case, but I gave it a try at https://codesandbox.io/s/recoil-playground-jkvq7?file=/pages/invalidation-option1.tsx:988-1059 and you're right that it does not. Although the hidden So, I think we have 2 feature requests here:
|
I am experimenting with a couple of extensions that might serve as a workaround. Interested to know if there are any obvious problems. /**
* Use a writable selector to prevent excess renders.
* If the setting value is equal to the current value, don't change anything.
*/
export function equalAtom<T>(options: EqualAtomOptions<T>): RecoilState<T> {
const { key, equals, ...innerOptions } = options;
const inner = atom({
key: `${key}_inner`,
...innerOptions
});
return selector({
key,
get: ({ get }) => get(inner),
set: ({ get, set }, newValue) => {
const current = get(inner);
if (!equals(newValue as T, current)) {
set(inner, newValue);
}
}
});
} /**
* Use a wrapper selector to prevent excess renders.
* If the latest selection is value-equal to prior ref, return the prior ref.
*/
export function equalSelector<T>(
options: EqualSelectorOptions<T>
): RecoilValueReadOnly<T> {
const inner = selector({
key: `${options.key}_inner`,
get: options.get
});
let prior: T | undefined;
return selector({
key: options.key,
get: ({ get }) => {
const latest = get(inner);
if (prior != null && options.equals(latest, prior)) {
return prior;
}
prior = latest;
return latest as T;
}
});
} |
@FokkeZB For the moment,
is the best solution for me and seems to work how I think selector re-renders should work. Would like to see this feature made stable. |
Note that you can also use a |
That would have a negative effect on the memory footprint though, right? |
It will be cached either way. You can adjust the cache policy. Currently selectors have independent cache policies; it might be interesting to enable selectors to share an LRU cache... |
@rmill777 @drarmstr be aware that the combination of @drarmstr two questions after running into this bug:
|
@FokkeZB Functions can't really be used for value equality because they can contain closure state which isn't easily captured for serialization and comparison. |
Here's a family version of the import {
ReadOnlySelectorFamilyOptions,
RecoilValueReadOnly,
selectorFamily,
SerializableParam
} from "recoil";
interface EqualSelectorOptions<T, P extends SerializableParam>
extends Pick<ReadOnlySelectorFamilyOptions<T, P>, "key" | "get"> {
equals: (a: T, b: T) => boolean;
}
export function equalSelectorFamily<T, P extends SerializableParam>(
options: EqualSelectorOptions<T, P>
): (param: P) => RecoilValueReadOnly<T> {
const inner = selectorFamily<T, P>({
key: `${options.key}_inner`,
get: options.get
});
const priors = new Map<string, T>();
return selectorFamily<T, P>({
key: options.key,
get: (param) => ({ get }) => {
const innerValue = inner(param);
const latest = get(innerValue);
const priorKey = innerValue.key;
if (priors.has(priorKey)) {
const prior = priors.get(priorKey) as T;
if (options.equals(latest, prior)) {
return prior;
}
}
priors.set(priorKey, latest);
return latest;
}
});
} Kudos to @bryanhelmig for the idea to use a Demo (fork) |
Would be awesome to short-circuit when the reference is known to be the same and not bother calling the const latest = get(inner);
if (prior != null && (lastest === prior || options.equals(latest, prior))) {
return prior;
} |
Added the reference equality check here, as it's free. As a newbie to this entire library, this may be stupid-obvious, but my long use of OWL/MFC/COM/WinForms/etc. makes me immediately concerned for the bloat in this |
@IDisposable good idea to do a simple equality check first, although that is also what Lodash' To avoid |
Since the quality of the |
I'd love to see this feature available, thought that's very prioritar for a react state management solution 😄 |
Proposal
I would like to be able to specify a custom function for cache comparison on an
atom
/selector
so I can tweak the behaviour.Let's say we have a configurable algorithm with multiple steps, and some options that pertain to a step each.
Now, only step 2 options are relevant for the step 2 of the algorithm, so we make a selector to fetch only that
That way, we don't trigger an unnecessary rerender when doing
setOptions({ ...options, featureA: !options.featureA })
.Motivation
This can currently be done using an intermediate
selector
, but it's a lot of boiler plate and makes the code look clunky.Here's a code sandbox showing the intermediate
selector
pattern: https://codesandbox.io/s/busy-cherry-u4rx6?file=/src/App.tsxThe real life use cases for this are when you get a state object from a backend, which you want to split out into multiple parts, or if you have a form for configuration of something, and you want to save the state of the form as is.
The text was updated successfully, but these errors were encountered: