Skip to content
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] unstable_createStore #861

Closed
dai-shi opened this issue Nov 30, 2021 · 47 comments · Fixed by #922
Closed

[RFC] unstable_createStore #861

dai-shi opened this issue Nov 30, 2021 · 47 comments · Fixed by #922

Comments

@dai-shi
Copy link
Member

dai-shi commented Nov 30, 2021

If #854 is successfully merged, we would like to consider exposing unstable_createStore.

import { atom, Provider, unstable_createStore } from 'jotai'

const countAtom = atom(0)
const store = unstable_createStore()

const App = () => (
  <Provider unstable_createStore={() => store}>
    ...
  </Provider>
)

// read atom value outside React
const value = store.get(countAtom)

// write atom value outside React
store.set(countAtom, 1)
store.set(countAtom, (c) => c + 1)

// subscribe to atom change outside React
const unsub = store.sub(countAtom, () => {
  console.log('countAtom can be changed')
  const value = store.get(countAtom) // this can be the same value as the previous one
})
@a-eid
Copy link

a-eid commented Dec 1, 2021

would it also be possible to set an atom value from outside react ?

@dai-shi
Copy link
Member Author

dai-shi commented Dec 1, 2021

Yeah, that's the plan. Updated description.

@a-eid
Copy link

a-eid commented Dec 1, 2021

@dai-shi that would be amazing.

@loganvolkers
Copy link

This would be great!

It would let us use jotai with lit, haunted, and stencil via https://github.com/saasquatch/universal-hooks

@timedtext
Copy link

Is it possible to release an alpha version now?

@dai-shi
Copy link
Member Author

dai-shi commented Dec 10, 2021

Please give us more time. It's still RFC. It's actually helpful if you can note why you need it. (And, maybe we can discuss current possible workaround.)

@timedtext
Copy link

Sorry, didn't mean to rush, just hope to use this feature soon.
In my case, I need to perform transient updates, similar to this one: #610

@dai-shi
Copy link
Member Author

dai-shi commented Dec 10, 2021

So, your comment is valuable, because this doesn't allow transient updates. store.set() will trigger re-render.

@timedtext
Copy link

Oh, it's a lucky thing I replied. I usually just read and don't reply because my English is not good.

Related Codes:

  const offsetRef = useRef(useStore.getState().offsets[index]);
  useEffect(
    () =>
      useStore.subscribe(
        (state) => state.offsets[index],
        (offset: Offset) => {
          offsetRef.current = offset;

          animation.animateTo({
            transform: [{ translateX: offset.x }, { translateY: offset.y }],
          });
        },
      ),
    [],
  );

@dai-shi
Copy link
Member Author

dai-shi commented Dec 10, 2021

jotai doesn't have a proper way for transient updates.
In some of utils, we do have a hack,

// TODO we should revisit this for a better solution than refAtom
const refAtom = atom(() => ({} as { prev?: Slice }))

but it's not something to recommend, and hope to revisit this in the future.

I guess we should keep the mutable store outside of jotai, and jotai atom only keeps an identifier to it. Like, atom/useAtom is for useState, and for useRef, just module variable.

@timedtext
Copy link

I found that zustand/vanilla can be embedded nicely in jotai. And its code is very clear.

Jotai's code is a bit larger. I don't know what's preventing it from implementing transient updates yet.

@dai-shi
Copy link
Member Author

dai-shi commented Dec 14, 2021

zustand and jotai are based on different models. (otherwise, we wouldn't develop jotai.)
Jotai is trying to be useState replacement. Transient updates are not React-ish. Basically, it's just reading a mutable variable in render, which can lead tearing in concurrent rendering.

@justjake
Copy link
Contributor

justjake commented Dec 30, 2021

@dai-shi I would like unstable_createStore. My use-case is to (consider) migrating large existing applications to Jotai. My existing model has various Flux-style state containers that are accessed outside React. In order to perform my migration to Jotai I will need to maintain existing interfaces for a long time.

Here is my idea. Feel free to browse but please be aware that the code in this repo is under AGPLv3.

https://github.com/justjake/monorepo/blob/b19d7542a2c56c5b77eda6b46a0a21908aff830e/packages/state/src/lib/implicitAtom.ts#L2-L19

You can think of this like building a Mobx-like system on top of Jotai, if Mobx used immutable writes.

@dai-shi
Copy link
Member Author

dai-shi commented Dec 30, 2021

My use-case is to (consider) migrating large existing applications to Jotai.

Interesting. I haven't thought about long-term migration and this api to help such use cases.

Does the proposed api look okay? Do you have any question about the behavior?

@justjake
Copy link
Contributor

@dai-shi when/how does the createStore prop get called by Provider? I assume this is called to create new scopes - so does returning a static Store here disable the scope feature?

@justjake
Copy link
Contributor

justjake commented Dec 31, 2021

Scope isn’t important to my use-case but it could affect 3rd-party libraries that happen to use Jotai internally and use scopes for isolation or something, right?

@justjake
Copy link
Contributor

justjake commented Dec 31, 2021

I think I also need subscription capability like store.subscribe(atom, () => doSomething(store.get(atom)))

@dai-shi
Copy link
Member Author

dai-shi commented Dec 31, 2021

when/how does the createStore prop get called by Provider?

createStore will be called on the initial mount. It's the same how it works internally now. (I'm not super confident if this is intuitive for developers, which is one reason why it's unstable_ now.)

I assume this is called to create new scopes

Hm, maybe not.
I just noticed changing scope after mount is not supported anyway.
If someone needs to change scope afterward, they would need key={}.

I think I also need subscription capability

It's fair! Just added in the description.

@justjake
Copy link
Contributor

justjake commented Dec 31, 2021

Should that read countAtom is changed or countAtom may need to recompute? Ideally this would be exactly the current internalStore[SUBSCRIBE_ATOM] behavior which gives the consumer the most flexibility, and not something that does internalStore[READ_ATOM]. It should be up to the subscriber to decide when they call publicStore.get(atom). That gives me more latitude to schedule computation (I use requestAnimationFrame and other sorts of shenanigans) or mimic my system’s existing subscription behavior.

@dai-shi
Copy link
Member Author

dai-shi commented Dec 31, 2021

Good point. I was thinking about not notifying if invalidated (r === i). But, you are right about the flexibility. Updated the description. I guess this is still in the research field.

@justjake
Copy link
Contributor

justjake commented Jan 1, 2022

If you want to move forward to a prototyping stage I think you could just make the existing createStore and the constants to access its methods “public” as a const like export const UNSTABLE_createStore = { createStore, READ_ATOM, … } as const which might allow easier prototyping… I forked Jotai to get these things to start prototyping as @jitl/jotai on NPM, but making the fork work is pretty annoying.

@dai-shi
Copy link
Member Author

dai-shi commented Jan 1, 2022

I thought about it once but my current plan is to provide a wrapped store with intuitively named methods.

@justjake
Copy link
Contributor

justjake commented Jan 1, 2022

Another topic to discuss is how internals will interact with the newly public createStore result. We should sketch the public type of this value - will it have all the existing internal methods? Will it have a private symbol that must point to the internals, so that users can’t produce their own implementation? Or will the internals start to use the public methods directly, and consumers can create their own original implementations as long as they implement the interface?

@dai-shi
Copy link
Member Author

dai-shi commented Jan 1, 2022

Here you go #922. Yeah, I have been thinking about making it a symbol for internal store or not.

@justjake
Copy link
Contributor

justjake commented Jan 1, 2022

I agree, that seems to be the safest direction for a v1 of this API -- at this point it would over-constrain Jotai's evolution to make it possible for consumers to implement their own store types.

@dai-shi dai-shi linked a pull request Jan 24, 2022 that will close this issue
@timedtext
Copy link

unstable_createStore can now directly implement transient updates, very exciting!
Great job!

@shamilovtim
Copy link

Question @dai-shi my understanding was that if we want state management outside of React to use Valtio. Now that this functionality is coming to Jotai where does that leave Valtio? I feel like we're more than happy to have state outside React being a second class citizen, as long as we have some option of state outside React. This makes choosing between Valtio and Jotai very challenging and might require updating the decision matrix of why choose one framework over the other

@dai-shi
Copy link
Member Author

dai-shi commented Feb 12, 2022

Great point.
Valtio vs. Jotai is rather easier because valtio provides mutation stytax and render optimization, and jotai has neither of them.
Zustand vs. Jotai gets harder with this change. I mean from the technical point of view jotai can cover the zustand use case. However, in practice, zustand provides minimal features with minimal size, so it's still a very valuable choice.
The decision matrix becomes complicated for sure...

@shamilovtim
Copy link

Is there anywhere I can read up on the mutation and render distinction? I thought Jotai was render optimised

@dai-shi
Copy link
Member Author

dai-shi commented Feb 12, 2022

https://blog.axlight.com/posts/how-valtio-proxy-state-works-vanilla-part/
https://blog.axlight.com/posts/how-valtio-proxy-state-works-react-part/

Yeah, both are render optimized. sorry for the confusion. jotai optimizes with atoms, valtio optimizes based on usage (property access).

@njzydark
Copy link

Yeah, both are render optimized. sorry for the confusion. jotai optimizes with atoms, valtio optimizes based on usage (property access).

Is this referring to the different granularity of control for rendering optimization?

For example, an object {title:'xx',content:'xx'}, in my understanding if the whole object is treated as jotai's atom, then if the title changes, the components that only depend on content will also render, while In Valtio, due to the proxy mechanism, there is no render. If I want to implement Valtio's granularity in jotai, it requires me to split title and content into separate atom?

@dai-shi
Copy link
Member Author

dai-shi commented Feb 12, 2022

@njzydark Yes, exactly.

@oozliuoo
Copy link

Thanks for providing this API! Just a follow-up question: is it true that writing an atom via this store api wont trigger a component re-render?

For example:

import { atom, Provider, unstable_createStore } from 'jotai'

const countAtom = atom(0)
const store = unstable_createStore()

const App = () => (
  <Provider unstable_createStore={() => store}>
    <ComponentA />
  </Provider>
)

// read atom value outside React
const value = store.get(countAtom)

// Now say I have a component
```ts
function ComponentA() {
  const count = useAtomValue(countAtom);

  return <div>Hello, {count}</div>
}

And then outside of React (for example, in Chrome devtool console), if I update the atom like

store.set(countAtom, 10);

will componentA be re-rendered in this case? Many thanks!

@dai-shi
Copy link
Member Author

dai-shi commented Jul 27, 2022

It will re-render even if the atom value is updated from outside React.

@matthew-dean
Copy link

matthew-dean commented Sep 18, 2022

The example code at the top of this issue doesn't make any sense to me:

const countAtom = atom(0)
const store = unstable_createStore()

// read atom value outside React
const value = store.get(countAtom)

// write atom value outside React
store.set(countAtom, 1)

How is the store getting / setting values if countAtom is never added to the store? Does this imply the store is global? Can there only be one store? Can there be two stores? Right now this isn't intuitive, as written.

Or is store like some kind of... special accessor of atom values? 🤔 If so, then it's not a store at all, right? Just some kind of ... value getting / setting utility??

@matthew-dean
Copy link

matthew-dean commented Sep 18, 2022

Like, if the code was written like this, it would make more sense:

const store = unstable_createStore({
  count: 0
})

// read atom value outside React
const value = store.get('count')

// or without store, because why do we need store?
const countAtom = atom(0)
const value = countAtom.get()

As it is right now, it doesn't make any sense what "store" is doing, because it seems like an indirect accessor / setter of an atom's value. Is it a manager? What is it?

@dai-shi
Copy link
Member Author

dai-shi commented Sep 18, 2022

What you expect from "store" is different.
Here, it just means "atom values". Internally, it's WeakMap<atom, atom value>. So, you can assume it a manger to access the internal atom values.

@matthew-dean
Copy link

Okay. Would it make sense to call it something other than "store"? Because then it isn't a store since it doesn't contain references to values.

@dai-shi
Copy link
Member Author

dai-shi commented Sep 18, 2022

No, we should still call it "store". It contains values.
I'm not sure if I can convince you, but here's some analogy.

store is mutable, and store holds (often) immutable states.

redux store

redux state is a (big) object.

const store = createStore(...)
store.getState() // returns a state object
store.dispatch() // to update the state object immutably

zustand store

zustand state is a (big) object too.

const store = createStore(...)
store.getState() // returns a state object
store.setState(...) // to update the state object immutably (by convention)

valtio store

valtio state is a mutable object, so it's a store. (but usually, I call it state instead of store)

const store = proxy(...)
store // is a state object
snapshot(store) // return an immutable state object
store.foo = ... // you can only update part of the store

jotai store

jotai state is only accessible with atom references. state is not a single object, but it consists of pieces.

const store = createStore()
store.get(fooAtom) // to return the piece of state by an atom reference
store.set(fooAtom, ...) // and to update it.

@matthew-dean
Copy link

matthew-dean commented Sep 18, 2022

I guess what I mean is: in Jotai, can I have multiple stores that consist of different sets of atoms? In all of those other examples you can. In every other example, those stores were setting up individual stores and explicitly setting what was in the store. In Jotai's example, you're not, and I feel like that omission is inherently confusing. In the semantic concept of a store, I should be identifying what's in the store. Your last example does not define or identify what's in the store. It just seems to set up an ethereal "concept of a store" which then gets items which were not identified as members of the store.

The mental model of your example is like:

const myHouse = createHouse()
const neighborsHouse = createHouse()
myHouse.get('Tesla') // returns a Tesla even though a Tesla was never added to my house
neighborsHouse.get('Tesla') // will this also return a Tesla? A different Tesla? It's not clear.

What will this do in Jotai?

const store1 = createStore()
const store2 = createStore()
store1.get(fooAtom)
store2.get(fooAtom)

If only store1 is valid, then it's not a store, OR createStore is not a valid method call name if only one singleton can exist. If both are valid, then it's also not a store, because neither store contains the value. If both are valid because the value is in the atom, but they need access to an internal WeakMap, then you're not creating a store! Do you see what I mean? Any semantic reading of the Jotai code doesn't make sense. Maybe this is the way Jotai has to work in terms of lines of code, and that's fine, but what you are calling things and naming things is not logical.

@dai-shi
Copy link
Member Author

dai-shi commented Sep 18, 2022

(Assuming your discussion is more in concept rather than in use cases) Yes, you can create multiple stores.

This works:

const store1 = createStore()
const store2 = createStore()
store1.get(fooAtom)
store2.get(fooAtom)

Jotai uses atom references as key instead of a string key, but conceptually it's the same as string key, so in mental model, it's analogous to this:

const store1 = createStore()
const store2 = createStore()
store1.get('foo')
store2.get('foo')

(without "initial value", they'd return undefined)

@dai-shi
Copy link
Member Author

dai-shi commented Sep 19, 2022

I think a pseudo implementation might help understanding it:

const createStore = () => {
  const map = new WeakMap()
  const get = (atom) => map.get(atom) ?? atom.init
  const set = (atom, value) => map.set(atom, value)
  return { get, set }
}

@matthew-dean
Copy link

matthew-dean commented Sep 19, 2022

@dai-shi Okay, maybe I'm really dumb and I'm missing something really really obvious, but in regards to:

Jotai uses atom references as key instead of a string key, but conceptually it's the same as string key

What I mean is: where are you setting the value of fooAtom into store1??

Let me rephrase: what is the output of this (from your example):

const countAtom = atom(0)
const store = createStore()
const value = store.get(countAtom)
console.log(value) // If I run this code, will it print '0'?

// secondly, what would be the value of this?
const store2 = createStore()
const value2 = store2.get(countAtom)
console.log(value2)  // If I run this code, will it also print '0'? How? What is happening?

Where are you putting countAtom INTO store (and not into a different store)? I do not see that in your example code? Is it just something I'm missing? This is what I can't figure out from the original code you posted. How are countAtom and store connected (and by implication, countAtom and store2 NOT connected)? Is this just missing lines of code in your example?

@matthew-dean
Copy link

matthew-dean commented Sep 19, 2022

@dai-shi

I think a pseudo implementation might help understanding it

Okay, but in your original example, you demonstrated getting the value BEFORE SETTING IT. So I took this to imply that store was a library-wide singleton that could access any atom's value defined anywhere because of the way you wrote your code example.

You literally wrote:

import { atom, Provider, unstable_createStore } from 'jotai'

const countAtom = atom(0)
const store = unstable_createStore()

const App = () => (
  <Provider unstable_createStore={() => store}>
    ...
  </Provider>
)

// read atom value outside React
const value = store.get(countAtom)

This is the only confusing thing to me. countAtom was never set before that point to be in store. Was that intended to not be a minimal example and just supposed to be understood that the two were tied together somewhere by someone setting the value into the store? If so, can you / should you update that original code example so someone else isn't baffled?

@justjake
Copy link
Contributor

countAtom has an initial state of 0. That’s represented by atom.init in the pseudo code above. The initial state is returned the first time you read an atom from a store.

@dai-shi
Copy link
Member Author

dai-shi commented Sep 19, 2022

Let me continue the pseudo code:

(edit: to make createStore function a little more like the real impl)

// -----------------------
// library code
// -----------------------
import { createContext, useContext, useRef } from 'react'

export const atom = (initialValue) => ({ init: initialValue })

const StoreContext = createContext()

export const Provider = ({ children, unstable_createStore }) => {
  const storeRef = useRef()
  if (!storeRef.current) storeRef.current = unstable_createStore()
  return <StoreContext.Provider value={storeRef.current}>{children}</StoreContext.Provider>
}

export const useAtom = (anAtom) => {
  const store = useContext(StoreContext)
  return [store.get(anAtom), (value) => store.set(anAtom, value)]
}

export const unstable_createStore = () => {
  const map = new WeakMap()
  const get = (atom) => {
    if (!map.has(atom)) map.set(atom, atom.init)
    return map.get(atom)
  }
  const set = (atom, value) => map.set(atom, value)
  return { get, set }
}
// -----------------------
// example code
// -----------------------
import { Provider, atom, useAtom, unstable_createStore } from 'jotai'

const countAtom = atom(0)
const store = unstable_createStore()

const App = () => (
  <Provider unstable_createStore={() => store}>
    ...
  </Provider>
)

// read atom value outside React
const value = store.get(countAtom) // returns initially `0`. See `?? atom.init`

// write atom value outside React
store.set(countAtom, 1) // will create a new entry in the WeakMap at first.

@matthew-dean
Copy link

matthew-dean commented Sep 19, 2022

@justjake

Wait so...

const countAtom = atom(0)

Sets up an object (countAtom) with an initializer, and this initializer returns a value of 0? And then creating a store and getting it, runs the initializer and puts the initial value into the store? 🤔 🤔 🤔 So running get before set is an implicit set? ...Oh, I see, a get is return of the weakmap's value, unless it doesn't exist, in which case it's the initial value. So then... it isn't in the store (or may not be) with a get. This is just a very confusing and unusual pattern to reason about, but I guess I get it. Maybe it's just that this is more familiar / intuitive for regular users of Jotai? As a newbie, it just doesn't resemble any other store API pattern.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

9 participants