-
-
Notifications
You must be signed in to change notification settings - Fork 623
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
[RFC] atom effects #211
Comments
@dai-shi I do my job with derived atoms, or even write-only atoms, these kinds of atoms could listen to other atoms and do things after updating, they are too easy to implement. I'm a volunteer. I'm thinking of option 1. |
Here's the withEffect that I'm thinking about: withEffect(anAtom, onSet: (get,set) => void)
// IDK if we need the newVal and oldVal like recoil( I'm not big fan of it,
// because I think withEffect should run after every update of the anAtom)
withEffect(anAtom, onSet: (get, set, {newVal, oldVal}) and if we want to use atomWithEffect we can make a function that creates the atom with the withEffect's onSet. Considering recoil's Atom effects:
|
Let me see how we could implement examples in recoil docs. Logging Examplehttps://recoiljs.org/docs/guides/atom-effects#logging-example const baseAtom = atom(null)
const currentUserIdAtom = atom(
get => get(baseAtom),
(get, set, arg) => {
set(baseAtom, arg)
console.log('Current user ID:', get(baseAtom))
}
) Note: The log is shown, when the update is scheduled, not committed. History Examplehttps://recoiljs.org/docs/guides/atom-effects#history-example const history = [];
const baseAtom = atom(null)
const userInfoAtom = atom(
get => get(baseAtom),
(get, set, arg) => {
const oldValue = get(baseAtom)
set(baseAtom, arg)
const newValue = get(baseAtom)
history.push({
label: `${JSON.serialize(oldValue)} -> ${JSON.serialize(newValue)}`,
undo: () => {
set(baseAtom, oldValue);
},
})
}
) Note: This would only work if baseAtom is not async. |
@dai-shi I think jotai is easier than recoil now, that's awesome. |
(Well, I didn't test them, and the behavior can be different. |
Continuing #211 (comment) State Synchronization Examplehttps://recoiljs.org/docs/guides/atom-effects#state-synchronization-example const baseAtom = atom(null)
const userInfoAtom = atom(
get => get(baseAtom) ?? myRemoteStorage.get(userID),
(get, set, arg) => {
if (arg === 'initialize') {
set(baseAtom, get(userInfoAtom))
myRemoteStorage.onChange(userID, userInfo => {
set(baseAtom, userInfo);
})
} else if (arg === 'cleanup') {
myRemoteStorage.onChange(userID, null);
}
}
)
// in component
const [userInfo, dispatch] = useAtom(userInfoAtom)
useEffect(() => {
dispatch('initialize')
return () => dispatch('cleanup')
}, []) So, we don't technically have atom effects. It's not equivalent. Write-Through Cache Examplehttps://recoiljs.org/docs/guides/atom-effects#write-through-cache-example const baseAtom = atom(null)
const userInfoAtom = atom(
get => get(baseAtom) ?? myRemoteStorage.get(userID),
(get, set, arg) => {
if (arg.type === 'initialize') {
set(baseAtom, get(userInfoAtom))
myRemoteStorage.onChange(userID, userInfo => {
set(baseAtom, userInfo);
})
} else if (arg.type === 'cleanup') {
myRemoteStorage.onChange(userID, null);
} else if (arg.type === 'set') {
set(baseAtom, arg.value)
myRemoteStorage.set(userID, arg.value)
}
}
)
// in component
const [userInfo, dispatch] = useAtom(userInfoAtom)
const setUserInfo = value => dispatch({ type: 'set', value })
useEffect(() => {
dispatch('initialize')
return () => dispatch('cleanup')
}, []) Local Storage Persistencehttps://recoiljs.org/docs/guides/atom-effects#local-storage-persistence See: https://github.com/pmndrs/jotai/blob/master/docs/persistence.md Browser URL History Persistencehttps://recoiljs.org/docs/guides/atom-effects#browser-url-history-persistence Your ideas... |
While I would try to avoid adding a new feature in core, this seems unavoidable.
So, here's the proposal. import { atom } from 'jotai'
const dataAtom = atom(null)
dataAtom.effects = [
(get, set) => {
const unsubscribe = someStore.subscribe((nextData) => {
set(dataAtom, nextData)
})
return unsubscribe
}
]
// effects are invoked in commit phase, when it first have a dependent
// and will be cleaned up when there are no dependents (even if it's a very short period.)
// `get` can read only from the committed state, wip state is just ignored. |
@dai-shi Nice way for effects. |
@Aslemammad suggested that If anyone has an idea about the use case of running functions on every changes, let us know. Given that there can be misunderstanding with New proposal. import { atom } from 'jotai'
const dataAtom = atom(null)
dataAtom.onmount = [
(get, set) => {
const unsubscribe = someStore.subscribe((nextData) => {
set(dataAtom, nextData)
})
return unsubscribe
}
]
// functions in `onmount` are invoked in the commit phase when it first have a dependent,
// and will be cleaned up when there are no dependents (even if it's a very short period.)
// `get` can read only from the committed state, wip state is just ignored.
// for async atoms, `get` will throw a promise in the pending state (tentative: we would change if React changes it.) I wonder if we want/need to make it a list. dataAtom.onmount = (get, set) => { ... } This looks more familiar, doesn't it? |
I think it's better now, and I think we can make it |
Hm, okay Here's version 3. import { atom } from 'jotai'
const dataAtom = atom(null)
dataAtom.onMount = (get, set) => {
const unsubscribe = someStore.subscribe((nextData) => {
set(dataAtom, nextData)
})
return unsubscribe
} |
I have been a bit less confident with the proposed api, and now I understand why. There's a pitfall with In short, it was not minimalistic. Here's the version 4. import { atom } from 'jotai'
const dataAtom = atom(null)
dataAtom.onMount = (setAtom) => {
const unsubscribe = someStore.subscribe((nextData) => {
setAtom(nextData)
})
return unsubscribe
} You may wonder how to update two atom values in one subscription. In this case, use a derived atom. const textAtom = atom('')
const countAtom = atom(0)
const derivedAtom = atom(
null,
(_get, set, nextText) => {
set(textAtom, nextText)
set(countAtom, nextText.length)
}
)
derivedAtom.onMount = (setAtom) => {
const unsubscribe = someStore.subscribe((nextData) => {
setAtom(nextData)
})
return unsubscribe
} |
Is it possible to use tools like |
@dzcpy I think if we use two atoms, the history can be implemented. No need for |
@dai-shi Thanks for your reply. I'll think about it and open a new issue if I can't solve it |
@dai-shi I feel like either I'm not understanding, or most of the responses here are missing the underlying sentiment of effects by suggesting that the answer is to put the interaction into hook-space. If I have jotai base atoms I want to update with results of some derived atom when that derived atom has reactive updates, my understanding is this would currently be done in a hook like: const myDerivedValue = useAtomValue(MyDerivedAtom);
const [value, setValue] = useAtom(MyBaseAtom);
useEffect(() => {
setValue(massageValue(myDerivedValue));
}, [myDerivedValue]); This has the problem of coupling Jotai-exclusive state logic with your React components, which imo kneecaps the library. I'm admittedly a Jotai beginner compared to my experience with Recoil, so I'm hoping this is just a learning opportunity for me. But if you're doing something some kind of sync between atoms, you now have to choose a point in your component hook-space code that's authoritative over the decision to sync those two atoms, which seems counter to Jotai's philosophy. |
When we designed You code snippet, const myDerivedValue = useAtomValue(MyDerivedAtom);
const [value, setValue] = useAtom(MyBaseAtom);
useEffect(() => {
setValue(massageValue(myDerivedValue));
}, [myDerivedValue]); is not good practice, not even with jotai, but with react. It requires two renders to get the final value and users would see intermediate state. Basically, it's delayed. What's better with jotai is you define a writable derived atom and update two atoms at the same time. This doesn't require hook-spece solution and no delay. |
That example was arbitrary, and my underlying point is that i wouldn't want to approach the problem with useEffect. I'll try and throw together a better example. For what it's worth, your response isn't all that helpful for me, or to the question posed. The trivial example i showed can be done differently, but the sentiment of wanting effects that accomplish similar patterns is still useful. The place where i do my writes isn't necessarily the place that makes sense to couple those particular items. A complex derived atom may have several ways of reactively updating, something that makes jotai great. Having to march my knowledge that something in a derived atom has changed up to the place where i make those base changes feels like it's kneecapping. Again, i may just be misunderstanding here, hopefully this is a more complete summary of the perceived problem I'm having. If I'm truly just approaching Jotai wrong, then that's alright. But based on the core principles of atomic state, my use case feels very intuitive, and somewhat reasonable. |
I think I misunderstood your question then. I thought it's related to [RFC] atom effects. Please open a new discussion with smallest possible concrete example. |
@dai-shi What were your reservations about onSet which would run on every commit? Its something recoil does, and could be useful for external sychronizations etc. |
#211 (comment) That doesn't mean I gave it up though. Maybe, we can try something with |
@dai-shi Its mostly regards synchronization with an external store or queue.
|
@ShravanSunder Let's first see if those use cases can be covered by a writable atom, which is preferable and more importantly, better in performance. |
@dai-shi do you have a recommendation for how to call an effect outside of React based on derived state? For instance: const postAtom = atom({ author: 'rileyjshaw', topic: 'Jotai' });
const authorAtom = selectAtom(postAtom, post => post.author);
// Call an effect whenever the value of `authorAtom` changes. I tried the following, but unless // Does not work, and feels a bit messy.
const effectAtom = atom(get => {
const author = get(authorAtom);
console.log(author); // <- Desired effect.
}); I realize that in this small example I could add an effect into In other words All of your examples above use a Edit 1: I just re-read some of the above posts, and it looks like this feature was intentionally excluded from Jotai.
I’m curious about how you would structure the following requirement in a writable atom, as you suggest here: #211 (comment). Let’s say I have a bunch of users which are dynamically loaded. I can also change my own username, which is a nested property under the users map. I want to persist my name to const myUserId = '123';
// Contents will eventually be overwritten.
const usersAtom = atom({
[myUserId]: localStorage.getItem('name') ?? '',
});
const myNameAtom = focusAtom(usersAtom, optic => optic.prop(myUserId).prop('name'));
function App () {
const setUsers = useSetAtom(usersAtom);
const [myName, setMyName] = useAtom(myNameAtom);
// Overwrite the contents of usersAtom every 10 seconds.
useEffect(async () => {
const intervalId = setInterval(() => {
const users = await fetch('/users');
setUsers(JSON.parse(users));
}, 10000);
return () => clearInterval(intervalId);
}, []);
return <input value={myName} onChange={e => setMyName(e.target.value)} />
} Since
const myNameStorageEffectAtom = atom(get => {
const name = get(myNameAtom);
localStorage.setItem('name', name);
return null;
});
function App() {
useAtomValue(myNameStorageEffectAtom);
// ...
}
function App() {
const [myName, setMyName] = useAtom(myNameAtom);
useEffect(() => {
localStorage.setItem('name', myName);
}, [myName]);
// ...
} Both solutions add an undesired render dependency, and couple the side-effect to React. I hope there’s a third solution that I don’t know about, like so:
// Just an example… I know this doesn’t exist:
myNameAtom.onUpdate(newName => localStorage.setItem('name', newName)); Edit 2: I just found #750, and I get the feeling that this is a use case you’re not interested in supporting. Fair enough! Both issues are a bit old, so if you’re open to discussing this further I think it would be a really useful feature. I like the minimal surface area approach that @ahfarmer outlined in #750, specifically: import { effect } from "jotai";
const destroy = effect((get, set) => {
// automatically re-runs whenever one of my get() values changes
// this is similar to an atom, except that it has set(), and does not have its own value
});
Sorry for the long issue, and thanks for all of the work that you do on Jotai, Zustand, Valtio, etc! Looking forward to hearing your thoughts when you get a chance, but no rush of course. |
@rileyjshaw Meanwhile, v2 API has store API, so you can do this: myStore.sub(myNameAtom, () => {
const maybeNewValue = myStore.get(myNameAtom);
localStorage.setItem('name', maybeNewName));
}); It can also lead to misusage, but at least there's something you can do, that wasn't possible previously. |
Thanks for responding so quickly @dai-shi! And sorry for commenting on an old issue. I won’t open a new discussion unless you think it’s important; I trust that you’ve got all the context you need and are keeping it in mind. The snippet that you posted from v2 appears close to what I was looking for, so I’ll start looking into v2’s store implementation. I think the clarity that I love so much about Jotai’s minimalism / separated concerns might just not extend to external effects. Whether I’m writing a Thanks again for all your work!! I just signed up as a monthly sponsor. |
Playing around with existing Jotai api. I absolutely love this library and other work from @dai-shi ❤️. Here's what I came up with for import { Atom, atom, Getter, Setter } from "jotai";
/**
* creates an effect atom which watches atoms with get and
* synchronously updates atoms with set.
*/
export function atomEffect(effectFn: Effector) {
const watchedValuesMapAtom = atom(new Map<Atom<any>, any>());
const cleanupAtom = atom<CleanupFn | null>(null);
const isFirstRun = (get: Getter) => {
const watchedValuesMap = get(watchedValuesMapAtom);
return watchedValuesMap.size === 0;
};
const someValuesChanged = (get: Getter) => {
const watchedValuesMap = get(watchedValuesMapAtom);
return Array.from(watchedValuesMap.keys()).some(
(anAtom) => !Object.is(get(anAtom), watchedValuesMap.get(anAtom))
);
};
const updateWatched = (get: Getter) => {
const watchedValuesMap = get(watchedValuesMapAtom);
Array.from(watchedValuesMap.keys()).forEach((anAtom) => {
watchedValuesMap.set(anAtom, get(anAtom));
});
};
const getFn = (get: Getter) => {
const watchedValuesMap = get(watchedValuesMapAtom);
const getter = <T>(anAtom: Atom<T>): T => {
const currentValue = get(anAtom);
watchedValuesMap.set(anAtom, currentValue);
return currentValue;
};
return getter;
};
const getSetCollector: GetSetCollector = (get) => (set) => {
if (!isFirstRun(get) && !someValuesChanged(get)) {
return;
}
updateWatched(get);
get(cleanupAtom)?.();
const cleanup = effectFn(getFn(get), set);
set(cleanupAtom, () => cleanup);
};
return atom((get) => {
const injectEffect = get(effectAtom);
if (typeof injectEffect === "function") {
injectEffect(getSetCollector(get));
}
});
}
type Effector = (get: Getter, set: Setter) => void | CleanupFn;
type CleanupFn = () => void;
type GetSetCollector = (get: Getter) => SetCollector;
type SetCollector = (set: Setter) => void;
type InjectEffect = (setCollector: SetCollector) => void;
function createSetInjector(set: Setter) {
return (setCollector: SetCollector) => {
setCollector(set);
};
}
const effectAtom = atom(
null as null | InjectEffect,
(get, set: Setter, _?: InjectEffect | void) => {
const value = get(effectAtom);
if (typeof value === "function") return;
const setInjector = createSetInjector(set);
set(effectAtom, setInjector);
effectAtom.init = setInjector;
}
);
effectAtom.onMount = (setAtom) => {
setAtom();
}; Usageconst updateComponentWhenCodeChangesAtom = atomEffect((get, set) => {
const code = get(codeAtom); // watched
set(updateComponentAtom, code); // applied
return () => {
// cleanup
}
}); Register
Register effect in the read function of another atomconst componentAtom = atom(get => {
get(updateComponentWhenCodeChangesAtom);
get(_componentAtom);
}); Register effect with a read hookuseAtom(updateComponentWhenCodeChangesAtom) Notes
AtomWithLazyThis pattern can be useful when dealing with atoms whose values require an expensive computation. import { atom, Getter, PrimitiveAtom } from "jotai";
import { atomEffect } from "./atomEffect";
export { atomWithLazy };
function atomWithLazy<T>(
lazyEvaluationFn: (get: Getter) => T
): PrimitiveAtom<T | null>;
function atomWithLazy<T, I>(
lazyEvaluationFn: (get: Getter) => T,
initialValue: I
): PrimitiveAtom<T | I>;
function atomWithLazy<T, I = null>(
lazyEvaluationFn: (get: Getter) => T,
initialValue: I | null = null
) {
const valueAtom = atom(initialValue as I | T);
const firstRunAtom = atom(true);
const effectAtom = atomEffect((get, set) => {
const firstRun = get(firstRunAtom);
if (!firstRun) return;
set(firstRunAtom, false);
const result = lazyEvaluationFn(get);
lazyAtom.init = result;
set(valueAtom, result);
});
const lazyAtom = atom(
(get) => {
get(effectAtom);
return get(valueAtom);
},
(get, set, value: I | T) => {
set(valueAtom, value);
}
) as typeof valueAtom;
lazyAtom.init = initialValue as I;
return lazyAtom;
} Usageconst globals = { React };
const codeMap = lessons.map(({ code }) => atom(code));
const initialValue: ComponentEntry = { Component: () => null, code: '' };
const componentAtom = atomWithLazy(
(get) => {
try {
const effectAtom = atomEffect((get, set) => { // <---- a lazily instantiated atomEffect 😁
const codeAtom = codeMap[lessonId];
const code = get(codeAtom);
set(componentAtom, code);
});
get(effectAtom);
const code = get(codeMap[lessonId]);
const Component = evalutateCode(code, globals) as React.FC;
return { Component, code } as ComponentEntry;
} catch (e) {
return initialValue;
}
},
initialValue
); Notes on atomWithLazy
|
As far as I understand, it looks like a nice hack. The code can probably simplified a little bit further.
Even Jotai internals don't know all atoms. How about initialize the map on mount and remove it on unmount? Naively, effects don't run for not-mounted atoms though, or we could clean up for each time.
Jotai v1 had a risk, but in v2, we update atom values outside React render cycle if the atom is already mounted. So, the question is if we can somehow deal with not-yet-mounted atoms. If you could solve those two issues, would you be interested in publishing |
One small but important fix: // this creates a shared map across multiple stores/Providers.
const watchedValuesMapAtom = atom(new Map<Atom<any>, any>());
// instead, do this:
const watchedValuesMapAtom = atom(() => new Map<Atom<any>, any>()); |
Yes. I'd love to contribute. 😊 I just have a few questions before I begin: type GeneralEffector = (get: Getter, set: Setter) => void;
type SpecificEffector<Value> = (get: Getter) => Value;
// current api
type AtomEffect = (effector: GeneralEffector) => Atom;
type AtomWithLazy<Value> = (effector: SpecificEffector<Value>, initialValue: Value)
|
1,2: My preference would be Please focus on the effect one first and once we agree on it, we can move on for others. I wonder if we should |
Some thoughtsThinking on this more, I found this thread because the problem I was trying to solve was that I needed to update an atom when another atom (atomWithStorage) was initialized / changed. I think this is a problem worthy of a solution. But it might better to restrict the api a bit. While atomEffect could update multiple atoms, I'm not yet convinced they should. However, one could achieve the same effect by writing to a writable atom which can update multiple atoms. atomEffect is basically a derived read-only atom with a ExperimentationBelow is some experimentation along with some thoughts. // atomEffect doesn't do anything until it is mounted somewhere
// but where should it be mounted? Inside `gtAtom` or 'lteAtom` or both? 😕
// Will this be confusing?
atomEffect((get, set) => {
const value = get(valueAtom);
if (value > 0)
set(gtAtom, expensiveGTComputation(value));
else
set(lteAtom, expensiveLTEComputation(value));
});
...
// what if gtAtom is unmounted?
// Now, lteAtom also stops getting updated, hmm...
const gtAtom = atom(get => {
get(atomEffect);
return get(_gtAtom);
});
...
const result = useAtomValue(atomEffect);
// when would its value ever be important / useful?
console.log(result); // undefined; 🙁 const actionAtom = atom(null, (get, set) => ({
increment: () => {
set(countAtom, count => count + 1);
},
decrement: () => {
set(countAtom, count => count - 1);
}
}));
const actionHackAtom = atomEffect((get, set) => {
return set(actionAtom);
});
...
// neat, but probably outside the patterns we would want to support
// note, that this component will update when any values watched by
// the atomEffect changes. In this case, none, but it can be difficult to tell,
// and feels like a misuse of useAtomValue.
const { increment, decrement) = useAtomValue(actionHackAtom);
// it might be nice to associate atom effects to any existing atom
const storageAtom = atomWithStorage('key', value);
const storageAtomWithEffect = withAtomEffect(storageAtom, get => {
if (get(countAtom) > 0) return `get(idAtom)-${count}`;
return get(storageAtom);
});
// this fixes the problem of where to mount atomEffects
// not sure on the use case, but withAtomEffect can be chained, but would be order-dependent
// if chained, each run should probably update the value of the host atom before the next effect runs.
// I think this is how Jotai currently behaves when atoms are set and later read in an atom setter. Recap
Cons
Cons
ConclusionWhile However, for updating a single atom's value, I feel |
In general, changing another atom when some atom changes is a bad practice as it's not "pure" or "declarative". So, it's technically a side effect. I would highly recommend to avoid such usage. However, in an edge case, it's useful and for exactly the case with atomWithStorage. (btw, I think we could improve atomWithStorage, or develop a new util specific for storage.)
Yeah, that looks cool. An interesting bit: const fooAtom = atom(
(get, { setSelf }) => {
const value = get(barAtom) * 2;
Promise.resolve().then(() => {
setSelf(value);
});
return value;
},
(get, set, value) => {
set(bazAtom, value + 1);
}
); |
idk, to me the code below feels more imperative and less ergonomic for some reason. const countAtom = atom(0);
const cleanupAtom = atom(null);
const countEffectAtom = atom(
(get, { setSelf }) => {
get(watchedAtom);
Promise.resolve().then(setSelf);
},
(get, set) => {
const cleanup = get(cleanupAtom);
cleanup?.();
const intervalId = setInterval(() => {
set(countAtom, count => count + 1)
}, 1000);
const cleanupFn = () => clearInterval(intervalId);
set(cleanupAtom, () => cleanupFn);
}
);
const countWithEffect = atom((get) => {
get(countEffectAtom); // register the effect
return get(countAtom);
}, (get, set, value) => {
set(countAtom, value);
}); I get that withAtomEffect v2To make withAtomEffect able to support side-effects, we can modify the api a bit. As with useEffect, the effect executes after all next state for the Jotai atom graph has evaluated. This should let us make to call setSelf synchronously in the effector as the entire effector function would be async. const countAtom = atom(0);
const countWithEffect: Atom = withAtomEffect(countAtom, (get, { setSelf }) => {
get(watched);
const intervalId = setTimeout(() => {
setSelf(count => count + 1);
}, []);
return () => {
clearTimeout(intervalId);
};
}); Open questions.
|
I mean using
setSelf can't be called in sync by design. If something expects to run in sync, it doesn't work as expected.
what does "live in an object" mean? btw, we have |
Since setSelf is commonly used by developers, I moved it out of options and moved options to the third arg. Hopefully, this function signature is ok. I don't see any way to make type SetSelf<Args, Result> = (...args: Args) => Promise<Result>;
type CleanupFn = () => Promise<void> | void;
type PromiseOrValue<Value> = Value | Promise<Value>;
type Effector<Args, Result> = (
get: Getter,
setSelf: SetSelf<Args, Result>
) => PromiseOrValue<CleanupFn> | PromiseOrValue<void>
type AtomWithEffect<Value, Args, Result> = WritableAtom<Value, Args, Result>;
type WithAtomEffect<Value, Args, Result> =
(anAtom: Atom<Value>, effector: Effector<Args, Result>) => AtomWithEffect<Value, Args, Result> |
Hm, I guess |
All, This thread taught me that there are more folks out there who would be interested in an atomEffect utility, and gave me the inspiration and motivation to build it. You may find the docs here: https://jotai.org/docs/integrations/effect Thank you |
Continuing from discussions in the discord channel.
Recoil proposes Atom Effects.
We don't need to provide the same API, and probably it's impossible as there are some conceptual differences between jotai and recoil. (It is interesting to me that things get to be seen differently in jotai from recoil, because originally jotai is modeled after recoil.)
However, there are good use cases. It seems like there are three possibilities for each use case.
Collecting use cases might be important. Please share your ideas.
For 1, we could create new functions in jotai/utils:
withEffect
andatomWithEffect
.Any thoughts? volunteers?
The text was updated successfully, but these errors were encountered: