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

feat: add api for returning a valtio proxyState from a jotai atom derived from another atom value #5

Merged
merged 1 commit into from
Oct 7, 2023

Conversation

dmaskasky
Copy link
Member

@dmaskasky dmaskasky commented Oct 3, 2023

mutableAtom

mutableAtom wraps a value in a self-aware Valtio proxy. You can make changes to it in the same way you would to a normal js-object.

API Signature

function mutableAtom(value: Value, options?: Options<Value>): Atom<{ value: Value}>

Parameters

  • value (required): the value to proxy.
  • options (optional): allows customization with proxyFn for custom proxy functions.

Example

Count value is stored under the value property.

const countProxyAtom = mutableAtom(0)

function IncrementButton() {
  const countProxy = useAtomValue(countProxyAtom)
  return <button onClick={() => countProxy.value++}>+</button>
}

Options

Options include proxyFn which can be proxy or a custom function.

type ProxyFn<Value> = (obj: { value: Value }) => ({ value: Value })

type Options<Value> = {
  proxyFn: ProxyFn<Value>
}

Caution on Mutating Proxies

Be careful not to mutate the proxy directly in the atom's read function or during render in React components. Doing so might trigger an infinite render loop.

const countProxyAtom = mutableAtom(0)

atom(
  (get) => {
    const countProxy = get(countProxyAtom)
    countProxy.value++ // This will cause an infinite loop
  },
  (get, set) => {
    const countProxy = get(countProxyAtom)
    countProxy.value++ // This is fine
  }
)

@codesandbox-ci
Copy link

codesandbox-ci bot commented Oct 3, 2023

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 500401d:

Sandbox Source
React Configuration
React TypeScript Configuration

@dai-shi
Copy link
Member

dai-shi commented Oct 3, 2023

Thanks for opening up a PR.

API design questions:

  1. atomWithProxyEffect(initialValue) would be easier to implement than withProxyEffect(atomToSync). Do you want to attach a capability to an existing atom? It makes a little bit complicated (too flexible), which is another concern.
  2. Is it an effect? It sounds to me it allows "mutation". If something called withProxyEffect is possible, I think withEffect the same capability without proxy sounds possible. But, is there such a thing?

Overall, one of my questions is if this falls into the "impure" category of Jotai mental model.

@dmaskasky
Copy link
Member Author

dmaskasky commented Oct 3, 2023

atomWithProxyEffect(initialValue) would be easier to implement

It wouldn't be too much work to convert the code to (1). 😅
I like this idea too, it prevents developers from passing a writable atom to the utility that doesn't accept args of the form [Value].

I think withEffect the same capability without proxy sounds possible.

It might be possible, not sure. But I wouldn't be in favor of reverse engineering Valtio around atomEffect. The two are not similar. withProxyEffect must return a useable mutable proxy from first read.

my questions is if this falls into the "impure" category of Jotai mental model.

The utility does allow Valtio-like imperative mutations similar to $state returned by useProxy from valtio/utils.

When sync: true, the utility tries to perform these mutations synchronously.

That is...

get(countAtom) // 0
$count.value++
get(countAtom) // 1

sync: false updates with a delay of Promise.resolve().then.

I should note that sync:true is synchronous only after onMount has fired, so initial read is still treated the same as sync: false. But synchronous imperative updates on mount is an anti-pattern so this is not a case we should handle.

(i.e. the following use case should not be supported)

const countAtom = atom(0)
const proxyCountAtom = withProxyEffect(countAtom, { sync: true })

function SomeComponent() {
  const $count = useAtomValue(proxyCountAtom)
  $count.value++
  const count = useAtomValue(countAtom)
  expect(count).toBe(1) // it will be 0 because count is updated on next microtask
    ...
}

@dai-shi
Copy link
Member

dai-shi commented Oct 3, 2023

I think withEffect the same capability without proxy sounds possible.

I think I misunderstood something. Proxies are required for mutation. So, I think it's different from atom-effect's effectFn.
I would probably call it atomWithMutability or mutableAtom.

When sync: true, the utility tries to perform these mutations synchronously.

It feels to me that sync: true should be default and not configurable.

$count.value++

This should be prohibited anyway in render. It should follow Jotai's principle.

@dmaskasky
Copy link
Member Author

atomWithMutability or mutableAtom

😮 Oh I like those choices. I wasn't too fond of withProxyEffect, it was just a placeholder.

It feels to me that sync: true should be default and not configurable.

Yes, we should lock it to sync: true so that it behaves the same as synchronous updates in Jotai.

This should be prohibited anyway in render. It should follow Jotai's principle.

Agreed, but mutableAtom does allow for some strange patterns when used inside other atoms.
For instance, mutating in the read is possible which causes an infinite loop. I wish there was some way to prevent this.

const countProxy = mutableAtom(0);

const readOnlyAtom = atom(get => {
  const $count = get(countProxy)
  $count.value++ // infinite loop ⚠️
});

@dai-shi
Copy link
Member

dai-shi commented Oct 3, 2023

I wish there was some way to prevent this.

I wish it too. For now, I think it should be clearly documented.

@dmaskasky
Copy link
Member Author

Requested changes complete.

  1. rename withProxyEffect to mutableAtom
  2. mutableAtom accepts a value, not an atom
  3. test that mutableAtom can accept function as value
  4. add warning to readme for infinite loop when proxy is mutated on atom read or component render
  5. remove sync option and hard-code sync config to true

@dmaskasky dmaskasky force-pushed the jotai-to-valtio-proxy-state branch from d099fd9 to abbcd70 Compare October 3, 2023 20:52
Copy link
Member

@dai-shi dai-shi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would generally avoid leaking general get and set to avoid misusing in our code.
Instead, I'd pass around store and proxyRef (unless combined) objects, and setValue function.
Functionality-wise, it should be the same, so basically just a suggestion for coding preference.

src/mutableAtom/types.ts Outdated Show resolved Hide resolved
src/mutableAtom/index.ts Outdated Show resolved Hide resolved
src/mutableAtom/index.ts Outdated Show resolved Hide resolved
src/mutableAtom/index.ts Outdated Show resolved Hide resolved
function ensureProxyState(get: Getter, setCb: SetCb) {
const proxyRef = get(proxyRefAtom)
const store = get(storeAtom)
if (!store.isMounted) createProxyState(get, setCb)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me isMounted is somewhat misleading.
I would simply do:

if (!proxyRef.current) {
  proxyRef.current = ...
}

Now, it may require combining proxyRefAtom and storeAtom, which I think simplifies the entire code, but I'm not sure.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can add both conditions.

For reasons I don't understand, when I set proxyRef.current = null on unmount, then after resubscribing and recreating the proxy on remount, mutating the proxy does not trigger subscribe callback.

Sequence

  1. unmount:
    a. proxyRef.current = null
    b. unsubscribe()
  2. remount:
    a. proxyRef.currrent = proxyFn({ value })
    b. subscribe()
  3. mutate proxy:
    a. proxyState.value++
    b. // subscribe does not fire

Solution: don't do proxyRef.current = null. I cannot understand why this is.

@dmaskasky
Copy link
Member Author

What should happen if proxyState were to be read / written to after the atom has unmounted?

  • do nothing?
  • throw?

@dmaskasky
Copy link
Member Author

dmaskasky commented Oct 4, 2023

I would generally avoid leaking general get and set to avoid misusing in our code.
Instead, I'd pass around store and proxyRef (unless combined) objects, and setValue function.
Functionality-wise, it should be the same, so basically just a suggestion for coding preference.

I refactored storeAtom.onMount and merged it with proxyRef. Does this resolve your concern?

createProxyState is used in:

  1. storeAtom write function (uses get and set)
    - for store.onMount
  2. proxyEffectBaseAtom read function (uses get and setSelf)
    - for synchronous first read
  3. proxy wrapper get and set functions (uses get and setSelf)
    - for synchronous remount

I think it is better to centralize this logic, but let me know if you prefer I break this up.

@dai-shi
Copy link
Member

dai-shi commented Oct 4, 2023

What should happen if proxyState were to be read / written to after the atom has unmounted?

BTW, reading/writing atoms without mounting is totally valid operations.

import { createStore, atom } from 'jotai';

const store = createStore();
const countAtom = atom(0);
store.set(countAtom, 1);
console.log(store.get(countAtom));

This is true, even after mounting and unmounting.

src/mutableAtom/index.ts Outdated Show resolved Hide resolved
@dai-shi
Copy link
Member

dai-shi commented Oct 4, 2023

Hm, actually this is interesting.
My gut feeling is we don't need to re-create proxyState. Just create once and subscribe on creation. We never unsubscribe. Everything would be garbage collected if the atom is garbage collected.
I think it will clean things up and become less hacky.
(I might be missing something though...)

@dmaskasky
Copy link
Member Author

BTW, reading/writing atoms without mounting is totally valid operations.

it('should allow updating even when component has not mounted', async () => {
  const store = createStore()
  const countAtom = atom({ value: 0 })
  const mutableCountAtom = makeMutableAtom(countAtom)
  await act(async () => {
    const countProxy = store.get(mutableCountAtom)
    countProxy.value++
  })
  expect(store.get(countAtom).value).toBe(1) // true 😮
})```

@dmaskasky
Copy link
Member Author

Great suggestions!

My gut feeling is we don't need to re-create proxyState. Just create once and subscribe on creation. We never unsubscribe. Everything would be garbage collected if the atom is garbage collected.

Done.

I would generally avoid leaking general get and set to avoid misusing in our code.
Instead, I'd pass around store and proxyRef (unless combined) objects, and setValue function.
Functionality-wise, it should be the same, so basically just a suggestion for coding preference.

Done.

Copy link
Member

@dai-shi dai-shi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getting better, but I think we can do more.

void
>(
(get, { setSelf }) => ({
proxyState: null,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can create a new proxy state here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm struggling to get proxy({ value: get(valueAtom) }) into the store.
This causes the store to recalculate on value change, but then it would need to subscribe / unsubscribe every time value changes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, so, it's not here.
I don't prefer getValue: () => get(valueAtom).value, in that sense. 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I'm not sure what you mean.

When the mutable atom is first read it creates a proxy and saves it to the store. On next read, the existing proxy is used.

Proxy is needed on first read. This can happen before the first atom mount event. So the only way I can think to synchronously create the proxy once on first read is to check the static store for its existence.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/mutableAtom/index.ts Outdated Show resolved Hide resolved
Copy link
Member

@dai-shi dai-shi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I get interested in the implementation, I will work on a PR to your PR. You can then accept/reject/partially take it.

void
>(
(get, { setSelf }) => ({
proxyState: null,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, so, it's not here.
I don't prefer getValue: () => get(valueAtom).value, in that sense. 🤔

src/mutableAtom/index.ts Outdated Show resolved Hide resolved
@dai-shi
Copy link
Member

dai-shi commented Oct 6, 2023

Here you go: dmaskasky#1

Copy link
Member

@dai-shi dai-shi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer used.

package.json Outdated Show resolved Hide resolved
package.json Outdated Show resolved Hide resolved
@dmaskasky dmaskasky force-pushed the jotai-to-valtio-proxy-state branch from 4a3fed9 to 3533652 Compare October 6, 2023 02:50
src/mutableAtom/index.ts Outdated Show resolved Hide resolved
@dai-shi
Copy link
Member

dai-shi commented Oct 6, 2023

By the way, I would simply use expect. Is assert simply for a shorthand?

I mean the goal is to drop minimalistic-assert completely.

Please also resolve conflicts.

@dmaskasky
Copy link
Member Author

dmaskasky commented Oct 6, 2023

I mean the goal is to drop minimalistic-assert completely.

I chose minimalistic-assert because another package we are depending on already uses it. Plus it is a devDependency so it won't increase the bundle size. I'm happy to write the logic for this too as it is simply

function assert(value: boolean, message?: string) {
  if (!value) {
    throw new Error(message ?? 'Assertion failed')
  }
}

By the way, I would simply use expect. Is assert simply for a shorthand?

@testing-library has a strange behavior where waitFor can run an expect that passes twice. This causes expect.assertions to fail.

@dmaskasky dmaskasky force-pushed the jotai-to-valtio-proxy-state branch 2 times, most recently from cc8bb18 to 980f7bd Compare October 6, 2023 23:42
@dai-shi
Copy link
Member

dai-shi commented Oct 6, 2023

https://github.com/testing-library has a strange behavior where waitFor can run an expect that passes twice. This causes expect.assertions to fail.

Hmm, curious how it fails.

I'm happy to write the logic for this too as it is simply

Yeah, that's much easier for me to read. Let's do that unless we find a solution to the above issue.

@dmaskasky
Copy link
Member Author

Hmm, curious how it fails.

It runs in a setTimeout loop, but an extra run can occur when expect is successful. The internet told me to use assert instead.

Yeah, that's much easier for me to read. Let's do that unless we find a solution to the above issue.

Done.

@dai-shi
Copy link
Member

dai-shi commented Oct 7, 2023

The diff looks weird. For example, it shouldn't show changes in .github.

@dmaskasky dmaskasky force-pushed the jotai-to-valtio-proxy-state branch 3 times, most recently from ec1053d to dfb5d88 Compare October 7, 2023 01:25
@dmaskasky dmaskasky force-pushed the jotai-to-valtio-proxy-state branch from dfb5d88 to 500401d Compare October 7, 2023 01:27
@dmaskasky
Copy link
Member Author

The diff looks weird. For example, it shouldn't show changes in .github.

Hopefully this is resolved now. I was in rebase hell for the past hour.

Copy link
Member

@dai-shi dai-shi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@dmaskasky dmaskasky merged commit 6d097d4 into jotaijs:main Oct 7, 2023
1 check passed
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 this pull request may close these issues.

2 participants