-
Notifications
You must be signed in to change notification settings - Fork 7
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
Complex Params with Singleton Atom #127
Comments
Pretty sure that if you pass a function as a parameter you’ll see the part
of the key it generates is just undefined or null - can’t remember exactly
off top of my head. So if your function changes, Zedux won’t treat it as a
new parameter and won’t trigger a new instance. Probably because at some
point JSON.strongly is called on the parameters to make the keys.
So, that’s a long winded way of saying you can probably pass in a function
that your atom can call to create its initial state for a store.
…On Sat, Nov 2, 2024 at 11:41 AM Nick DeCoursin ***@***.***> wrote:
@bowheart <https://github.com/bowheart> Thank you very much this
wonderful library. At least my first impressions are that it's amazing. I'm
in the process of learning it, but I think I have a pretty good overview.
I had a question, I was hoping you would help me with it.
I noticed that the atom state factory
<https://omnistac.github.io/zedux/docs/walkthrough/atom-instances#atom-params>
"creates a different atom instance for every 'unique' set of params you
pass" and unlike React, "Zedux doesn't compare object references.
Internally, Zedux turns all params into a single string."
Imagine, however, if I pass a huge list where each item in the list is a
deeply nested object with many, many properties, and I want to use this as
the initial state of the injected atom's store. This doesn't seem to bode
well however with how the atom state factory works.
It only needs to be a singleton store/atom.
How would you recommend that I solve this? I have considered the following
solutions, but IDK exactly:
- Setting complexParams: true when creating the ecosystem. The example
<https://omnistac.github.io/zedux/docs/advanced/complex-params#complexparams>
you gave was just for a function as argument, but perhaps this would also
work for arrays/objects?
- Creating an atom where the valueOrFactory
<https://omnistac.github.io/zedux/docs/api/factories/atom#valueorfactory>
is the store. But there are all sorts of problems that come with this.
- Perhaps somehow creating the store outside the atom, and then
passing the store into the atom state factory as its parameter? But
according to your docs the args of the atom state factory must be
serializable, and I would imagine that stores are not serializable?
Theoretically speaking, all I think I need for this case is a sort of
singleton atom with a predefined store. That store needs to change, but
that atom should never be recreated if the original params to it somehow
change.
I can try to give more detail, or perhaps you understand where I'm coming
from? It seems like this would be a sort of common use case, but I'm not
sure there exists a great solution for it yet?
—
Reply to this email directly, view it on GitHub
<#127>, or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ABBKOGN7T47FKGRPFC7635TZ6TXCPAVCNFSM6AAAAABRB2RUCCVHI2DSMVQWIX3LMV43ASLTON2WKOZSGYZTANJTG4ZTANQ>
.
You are receiving this because you are subscribed to this thread.Message
ID: ***@***.***>
|
Hi @ChrisBoon that's an interesting suggestion, but that approach is definitely a little hacky and would be confusing to people reading the code. If the I really think there should be a way to pass in other data to the atom state factory that is not included in the "hashing". Is there some reason that hasn't been done yet, or perhaps just oversight? |
Hey @decoursin great question. And that's a cool suggestion @ChrisBoon. I'm gonna play with that actually. Firstly, if you're running into this frequently, you're probably underutilizing Zedux. In an ideal setup, that big nested list would already be controlled by Zedux - e.g. it would be fetched/created in another atom rather than in a React component. The new atom could then simply access the list via an injector or atom getter. This setup also allows the new atom to react to changes in the list rather than taking a static snapshot on init. With that in mind, there are exceptions, especially when using 3rd party tools that rely on React hooks. So I'll assume that's the case here.
The ApproachesThere are a few ways to go about it. They all basically come back to one idea: Get that data in the ecosystem somehow. Use another atomconst externallyPopulatedAtom = atom('externallyPopulated', null)
const bigListAtom = ion('bigList', ({ get }) => {
const initialState = get(externallyPopulatedAtom)
const store = injectStore(initialState)
...
})
function SomeTopLevelComponent() {
const ecosystem = useEcosystem()
useBigListFetcher(
bigList => ecosystem.getInstance(externallyPopulatedAtom).setState(bigList)
)
...
} Set state after initconst bigListAtom = atom('bigList', () => {
const isInitializedRef = injectRef(false)
const store = injectStore([])
return api(store).setExports({
init: initialState => {
store.setState(initialState)
isInitializedRef.current = true
}
})
})
function SomeTopLevelComponent() {
const bigListInstance = useAtomInstance(bigListAtom)
useBigListFetcher(bigList => bigListInstance.exports.init(bigList))
...
} Note that this can usually be even simpler. I'll put that example towards the end. Use ecosystem contextEcosystems have an optional // With an ecosystem created like this:
const ecosystem = createEcosystem({
context: { bigList: null },
id: 'root',
})
// you can mutate and read from `ecosystem.context` at will
const bigListAtom = ion('bigList', ({ ecosystem }) => {
const store = injectStore(ecosystem.context.bigList)
...
})
function SomeTopLevelComponent() {
useBigListFetcher(bigList => {
ecosystem.context.bigList = bigList
})
} This could also work with your store suggestion - create a store outside the atom with any state, put the store on the context, and grab it in If you do find this approach useful, let me know. I'm considering potentially deprecating ecosystem context and removing it in the future. Note on Timing and ErrorsNone of these examples included a way to ensure that normal consumers of
We pretty much always use the last two options. Handle empty valueThis is the most common way to deal with timing issues - make consumers handle an empty default. They'll reactively update when the data does come in anyway. This is usually very easy with arrays since consumers can handle an empty array exactly the same as a non-empty array. So here's a full example of the most common way to initiate an atom with complex external data: const bigListAtom = atom('bigList', [])
function SomeTopLevelComponent() {
const bigListInstance = useAtomInstance(bigListAtom)
useBigListFetcher(bigList => bigListInstance.setState(bigList))
}
function SomeConsumerComponent() {
const bigList = useAtomValue(bigListAtom)
// works whether the list has been populated yet or not:
return bigList.map(item => <SomeItemComponent key={item.id} item={item} />)
} Usually this is all you need. Access via a selectorConsider this a postscript. This is an advanced pattern and may be overkill for you. If your question is already answered, ignore this. That said, we've started using this pattern in a few places and really like it. The gist:
// don't export the atom:
const bigListAtom = atom('bigList', ...)
// only export this atom selector:
export const getBigListInstance = ({ ecosystem, getInstance }: AtomGetters, initialState?: BigList) => {
if (!initialState) {
// make sure the instance already exists so the `getInstance` call doesn't create it
if (!ecosystem.find(bigListAtom)) throw new Error("can't access bigList before init")
return getInstance(bigListAtom)
}
// If it hasn't been initialized, initialize it
const instance = getInstance(bigListAtom)
instance.exports.init(initialState)
return instance
}
// then initialize the instance like so:
getBigListInstance(ecosystem._evaluationStack.atomGetters, bigList)
// and all other consumers access it without passing the arg. E.g. in React components:
useAtomSelector(getBigListInstance) // to get the instance
useAtomValue(useAtomSelector(getBigListInstance)) // to subscribe to the value |
@bowheart thank you very much for your very wonderful detailed description. It introduces some new ideas for me, and confirms others I wasn't totally certain about.
I'm actually using SSR with a very popular react meta framework. I'm fetching the data first on the server side, then passing it down through the client side as props. This is a common pattern that I'll be repeating throughout my app. For a lot of reasons, it's better to request the data immediately on the server side, rather than waiting for the client to request it inside an atom. I realize that Zedux supports SSR itself to a certain extent. Therefore, theoretically, I would maybe be able to instantiate the atom on the server side, then pass it down to the client somehow using Zedux' SSR capabilities by dehydrating and then rehydrating the ecosystem, but I don't think that's a very good/clean solution especial the ease and convenience of just passing the data down to the client as props. Your two suggestions "Use another atom" and "Set state after init" are nice, but they come with a ton of other problems which you outlined fabulously yourself. Actually, your "Use ecosystem context" suggestion would maybe be the best approach, but it doesn't seem that stable according to you (ha) and for other reasons like issues with type safety like you suggested and probably more difficulty debugging, etc. I'm so far in love with this library. But is there any functional reason why you don't want to add someway for users to pass additional data to the atom that isn't part of the hashing? I realize it would maybe require a large refactor or something, but it seems to me like it would be something feasible and have many uses cases. |
@decoursin Ah I was approaching this from a completely different angle. With SSR, it sounds like Zedux's hydration is what you want then. You don't have to pair it with dehydration on the server - it sounds like your framework already does that. Exactly how that looks depends on your setup. Without knowing that this is kind of a shot in the dark, but I know that in Next 15, you can call 'use client'
const bigListAtom = atom('bigList', [])
function MyClientComponent({ bigList }) {
const ecosystem = useEcosystem()
// hydrating right here may be fine depending on setup:
ecosystem.hydrate(
{ [bigListAtom.key]: bigList },
{ retroactive: false }
)
// now access bigListAtom
const listFromAtom = useAtomValue(bigListAtom)
return <div>Hydrated {listFromAtom.length} values</div>
}
It's just that it's unnecessary. If you're mutating ecosystem context, you have access to the ecosystem and can be updating the state of an atom instead: ecosystem.context.bigList = bigList // not type-safe, not trackable, not reactive
ecosystem.getInstance(bigListAtom).setState(bigList) // type-safe, trackable, reactive |
I've tried to setup the SSR using dehydration/hydration, and it gets really complex fast. It's definitely not the direction that would make sense to go. I would imagine you haven't done this before, because if you have, you know how complex it gets. For starters, moving my http request into an atom results in a Query Atom. Query Atom cause additional complexities. Query Atoms don't return pure data, they return Now imagine you have a Query Atom on the server that you want to Query Atoms return PromiseState, PromiseState complies with Suspense, but Server Components don't work with Suspense. Suspense is fundamentally meant to provide React with the capabilities of running concurrently inside Browsers - it's not meant for Server Components. Besides the problems causes by Query Atoms, you have all the hydration/dehydration code that becomes quite a mess. Hydrating on the server, dehydrating on the client, what should be "retroactive" what shouldn't. Performance penalties of hydration? Are my atoms serializable? Debugging issues. etc. I'm not trying to bash Zedux. Once again, I find Zedux to be amazing. But I personally think Zedux is a paradigm meant for client code / Client Components, it's not meant for server code / Server Components. The entire reactivity paradigm with "molecules" or "atoms" is a frontend/client paradigm, not a server paradigm. Once you start using Atoms on the server, half of the stuff doesn't even work anymore (at least for react it's so) for good reason, like Furthermore, once you have a codebase mixing server-side atoms with client-side atoms things start to become really complex and hard to grasp. Spaghetti code. It's like mixing Christmas in the Winter and Christmas in the Summer - it doesn't make sense / it's not consistent. All of this in comparison to just running my http request inline in TS then passing the data down to the clients as props. This is obviously far far easier and more maintainable. The other solutions that you offered are definitely better in my opinion than trying to do SSR with atoms. |
TL;DROnly use Zedux in client components. If there's a demand for using Zedux in RSCs, it's possible but we need better docs. Full Answer
@decoursin Query atoms set a 'use server'
export async function MyServerComponent() {
const queryInstance = serverEcosystem.getInstance(myQueryAtom)
await queryInstance.promise
return <MyClientComponent data={queryInstance.getState().data} />
} But before we get too caught up in that approach, we should come back to this: Zedux on the client
Yes! Exactly this. The mess you're seeing is due to how frameworks (especially nowadays with RSCs) work. It isn't specific to Zedux - it would be the same with any 3rd party state manager. This complexity is why most people using frameworks avoid 3rd party state managers completely. That may be my advice for you too. If Zedux isn't solving a problem or making things easier for you, you probably don't need it. That said, I do use Zedux in Next. Let me reiterate this:
Contrary to what I said before when I was assuming a client-only setup (which is where Zedux and all 3rd party state managers shine), it now sounds to me like you're trying to fight the framework by overutilizing Zedux. Your framework already has RSC support. I'd use that, not query atoms in Zedux. I believe frameworks could be much more powerful if they integrated more deeply with 3rd party state managers. Unfortunately they don't. That means lots of manual work if you want to use Zedux (or any similar lib) on both the server and the client. The fix is simple in theory, though will take a while to get used to (which is just RSCs in a nutshell and again isn't Zedux's fault, it's the same for any 3rd party state manager): Only use 3rd party state managers on the client. I really only know Next. I'll try to make some time to experiment with Remix and Waku. But in Next at least, the server counterpart to my hydration example is simply: 'use server'
export async function MyServerComponent() {
const bigList = await fetchBigList() // no Zedux involved
return <MyClientComponent bigList={bigList} />
} If you don't want to worry about const useAtomHydration = <T extends AnyAtomTemplate>(
atomTemplate: T,
val: AtomStateType<T>
) => {
const ecosystem = useEcosystem()
const existingAtom = ecosystem.find(atomTemplate)
// hydrate immediately if the atom doesn't exist yet (ideal)
if (!existingAtom) {
ecosystem.hydrate({ [atomTemplate.key]: val })
}
// defer to a useEffect to set ("hydrate") the existing atom's state safely
useEffect(() => {
if (existingAtom) existingAtom.setState(val)
}, [val]) // don't add `existingAtom` - it isn't reactive (and shouldn't be)
} Then use like so: function MyClientComponent({ bigList }) {
useAtomHydration(bigListAtom, bigList)
} Zedux on the ServerWhile I'd avoid using Zedux on the server in general, its advanced cache management does make it a suitable replacement for Next's This requires quite a bit of setup. Depending on the framework/compiler, there may be manual work you'd have to remember to do per-RSC too. But at least in Next when not using Turbopack, TypeScript's If people are interested in going down this route, then we should improve the docs. Some of your confusion is probably from the SSR guide in the docs. That's a low-level guide for doing SSR from scratch with no framework. It's probably misleading. We should bury that guide more and replace that with a guide for people using frameworks since that's way more common. Additionally, since frameworks are so common in general, we should probably add notes throughout many doc pages of APIs that are only designed for use on the client. |
Hi @bowheart thank you again for your detailed description. I see that I made a couple mistakes, but I also see that we agree on a lot of what I said - so that's good:) I will have to play around with your I really appreciate all the time you've given me on this. |
I've looked into all the options, and for the problem at hand I'll actually be using @ChrisBoon's solution. Thank you very much Chris! @bowheart thanks again for the wonderful library. I hope to contribute back in the future! |
Hey @bowheart, I've been playing around with the First, let me clarify how I'm hydrating. I have a top client component which receives props sent from a nextjs server component. The top client component looks something like this: export default function TopClientComponent({bigList}) {
const ecosystem = useMemo(() => createEcosystem({id: 'account-page-root'}), [])
ecosystem.reset() // always reset the ecosystem to avoid nextjs hydration errors (is there a better way?)
ecosystem.hydrate({ [someAtom.key]: bigList })
} Where my export const someAtom = atom('some', () => {
const store = injectStore<Set<string>>(hydration => {
return createStore(hydration)
}, { hydrate: true })
return api(store)
}, {
hydrate: (args) => {
// modify args then return new args
return Set<string>(args)
}
}); Now this was working fine actually, and I was even thinking about adopting this full-on and using this instead of the function as argument approach. However, this hydration stuff doesn't seem to work at all when used in combination with composed stores (ie. First of all, I have a lot of export functions in my biglist atom, and I would like these export functions to use values from stores located/created in other atoms. Therefore, I'm confused what would be the best approach for this. To solve this, I've gone down the path of or am rather currently attempting to use export const someAtom = atom('some', () => {
const external = injectAtomInstance(external1Atom).store
const store = injectStore<Set<string>>(hydration => {
return createStore(hydration)
}, { hydrate: true })
const awesomeExportFN = () => {
const { someExternalValue } = external.getState()
// use someExternalValue ...
}
return api(store).exports({ awesomeExportFN })
}, {
hydrate: (args) => {
// modify args then return new args
return Set<string>(args)
}
}); It would seem like there are different possible solutions for this, but I'm trying to figure out the best one or rather understand the trade-offs between them. Other possible solutions to this problem would entail:
Thank you very much for any insight. I see that you're working on |
Hey @decoursin. I am indeed working on the signals implementation and I see so many reasons why I wish it was done already 😄
There might be. I'd need more context of when you're running into hydration errors (on load, on redirect, when streaming, something else?) and how often the ecosystem gets reset and when. Reset might be fine, depending. Just understand that that's destroying all atoms and letting them be recreated from the leaf nodes (e.g. nodes used directly in React components). If it's possible to be more granular - only resetting specific atoms - that's usually better, but also may be much harder.
There are several quirks with hydrating composed stores as you saw in the signals doc. Fundamentally though, I'd say it works. Could you provide an example of it not working? Dropping this code in the first sandbox of the quick start doc works: const childAtom1 = atom('child1', () => 1)
const childAtom2 = atom('child2', () => ({ b: 2 }))
const parentAtom = atom('parent', () => {
const childInstance1 = injectAtomInstance(childAtom1)
const childInstance2 = injectAtomInstance(childAtom2)
const store = injectStore(
hydration =>
createStore(
{ child1: childInstance1.store, child2: childInstance2.store },
hydration
),
{ hydrate: true }
)
// ensure child store references stay up-to-date
store.use({ child1: childInstance1.store, child2: childInstance2.store })
return store
})
function Greeting() {
const ecosystem = useEcosystem()
useMemo(() => {
ecosystem.hydrate({ [parentAtom.key]: { child1: 11, child2: { b: 22 } } })
}, [])
const [val, setVal] = useAtomState(parentAtom)
return (
<div>
child1: {val.child1} child2.b: {val.child2.b}{' '}
<button onClick={() => setVal(state => ({ ...state, child1: state.child1 + 1 }))}>
increment a
</button>
</div>
)
} Can you show me how your approach differs from this? The one thing I see, though it may just be due to you writing pseudo-code here, is that the initial state should be the second argument to
Your last bullet is how we do this; use either an atom getter or the composed store inside the exported function. const external = injectAtomInstance(external1Atom).store
const store = injectStore(hydration => createStore({ external }, hydration), { hydrate: true })
store.use({ external })
const awesomeExportFN = () => {
get(external1Atom) // good - atom getters and the atom template reference are stable
store.getState().external // good - `injectStore` returns a stable store reference
external.getState() // bad - injected atom instances aren't technically stable references. `external` and its store could change if force-destroyed, so they shouldn't be used in atom exports
} |
haha
Well it seems the nextjs server has a copy of all the atoms sitting in memory. When some of the atoms on the client change, these changes are not being propagated back to the nextjs server, the changes remain on the client, so there's then a misalignment when the client reloads the page. However, the truth is is that I'm also quite new to nextjs, I'm more of a backend developer to be honest, and I'm starting to see the need to use suspense or use-effect to resolve the hydration errors. I was trying to get around using use-effect (or suspense) because that will slow down the time to First Meaningful Paint. But anyways this is my problem ;) not yours haha but thanks for your input - very helpful.
Yeah, I didn't completely read that through but I see them now.
Yeah, that was just a mistake in my pseudo-code, but definitely a mistake I've made a lot too 😄😄
Perfect, that's really what I needed to hear. Thank you. I'm doing the following now. I'm have this export const someAtom = atom('some', () => {
const { get } = injectAtomGetters()
const getExternal = () => {
return {privacies: get(privacyFilterAtom), search: get(searchFilterAtom), ... others}
}
const exportFN = () => {
const {privacies, search} = getExternal()
...
}
...
})
Yeah, that does seem to work, that's cool, that far was working for me too actually. What wasn't working for me was using that in tandem with the |
These are great reads, |
@bowheart Thank you very much this wonderful library. At least my first impressions are that it's amazing. I'm in the process of learning it, but I think I have a pretty good overview.
I had a question, I was hoping you would help me with it.
I noticed that the atom state factory "creates a different atom instance for every 'unique' set of params you pass" and unlike React, "Zedux doesn't compare object references. Internally, Zedux turns all params into a single string."
Imagine, however, if I pass a huge list where each item in the list is a deeply nested object with many, many properties, and I want to use this as the initial state of the injected atom's store. This doesn't seem to bode well however with how the atom state factory works.
It only needs to be a singleton store/atom.
How would you recommend that I solve this? I have considered the following solutions, but IDK exactly:
complexParams: true
when creating the ecosystem. The example you gave was just for a function as argument, but perhaps this would also work for arrays/objects?Theoretically speaking, all I think I need for this case is a sort of singleton atom with a predefined store. That store needs to change, but that atom should never be recreated if the original params to it somehow change.
I can try to give more detail, or perhaps you understand where I'm coming from? It seems like this would be a sort of common use case, but I'm not sure there exists a great solution for it yet?
The text was updated successfully, but these errors were encountered: