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: observe api for observing stores with effects #55

Merged
merged 3 commits into from
Jan 25, 2025
Merged

Conversation

dmaskasky
Copy link
Member

@dmaskasky dmaskasky commented Jan 17, 2025

Summary

I'm excited to introduce a brand new helper function, observe, which simplifies and unifies the way you manage side-effects in Jotai. By leveraging observe, you can cleanly subscribe to state changes, automatically run (and clean up) your effects, and even share subscriptions between multiple calls—all with minimal boilerplate.


API

Type

type Unobserve = () => void

function observe(effect: Effect, store = defaultStore()): Unobserve

Example

observe((get, set) => {
  console.log(`count is now ${get(countAtom}`)
})
  • Parameters:

    • effect – A function that receives Jotai’s get and set methods.
    • store (optional) – If omitted, observe uses the default store.
  • Returns:
    A cleanup function (unobserve), which stops the effect and removes subscriptions when called.


Key Features

  1. Streamlined Subscription
    Calling observe(effect, store) runs the effect immediately and on every relevant state change.

  2. Automatic Cleanup
    The returned unobserve function handles cleanup for you, freeing you from managing memory leaks or forgotten subscriptions.

  3. Idempotent Subscriptions
    Repeatedly calling observe(effect, store) reuses the same subscription. This means multiple parts of your app can rely on the same effect without incurring additional overhead.

  4. Independent Store Support
    You can attach the same or different effects to any number of stores. Each store maintains its own subscriptions, keeping your state logic isolated and modular.

  5. Multiple Effects
    Attach as many independent effects to a store as you need. Each effect is tracked separately, so unsubscribing one won’t affect the others.


Complete Example

import { atom, createStore } from 'jotai/vanilla'
import { observe } from './observe'

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

const unobserve = observe((get) => {
  console.log('Current count is', get(countAtom))
}, store)

// Now any change to `countAtom` will trigger the effect
store.set(countAtom, (v) => v + 1) // Logs: "Current count is 1"

// Cleanup when you’re done
unobserve()

Behind the Scenes

  • Caching Mechanism
    Internally, observe uses a WeakMap<Store, Map<Effect, () => void>> to avoid duplicate subscriptions.
  • Synchronous Updates
    Effects run synchronously in response to state changes.
  • Robust Operation
    The utility handles multiple stores and concurrent effects, ensuring consistent behavior in real-world scenarios.

With observe, Jotai’s side-effect management becomes more powerful and ergonomic. I hope this utility simplifies your workflows and keeps your codebase clean and maintainable. Feedback is always welcome!

Copy link

codesandbox-ci bot commented Jan 17, 2025

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.

@sebinsua
Copy link

  1. If you're using a <Provider store={store}> at the root of your app, will you need to expose this store and pass it in to observe as the second argument?
  2. What you mean by "share subscriptions between multiple calls"?
  3. Would someone call this from a React useEffect, or is this something that you'd call outside of React, for example within a function that creates/derives atoms?
  4. Presumably there is a different use case for this, compared to the two other public APIs?

@dmaskasky
Copy link
Member Author

dmaskasky commented Jan 17, 2025

Please be patient, the PR description is not very polished yet. I'll do another pass before I publish as docs. I appreciate the feedback, so thank you for your questions.


  1. If you're using a <Provider store={store}> at the root of your app, will you need to expose this store and pass it in to observe as the second argument?

If you are creating store in the global scope then you can just do

observe((get, set) => {
  console.log(`count is now ${get(countAtom}`)
}, store)

Alternatively, if you are creating the store inside a component, you can do

function effect(get: Getter, set: Setter) {
  console.log(`count is now ${get(countAtom}`)
}

function Component() {
  const store = useStore()
  observe(effect, store)
  ...
}

However, maybe the useAtomEffect recipe is better suited.
https://jotai.org/docs/recipes/use-atom-effect


  1. What you mean by "share subscriptions between multiple calls"?

observe is memoized by store object and effect function. Multiple invocations of the same store and same effect are deduplicated. The following code will invoke loggingEffect once per change to countAtom.

function loggingEffect((get, set) {
  console.log(`count is now ${get(countAtom}`)
}
observe(loggingEffect)
observe(loggingEffect)

  1. Would someone call this from a React useEffect, or is this something that you'd call outside of React, for example within a function that creates/derives atoms?

From above, I think the useAtomEffect recipe is better suited for calls made within a component.
https://jotai.org/docs/recipes/use-atom-effect


  1. Presumably there is a different use case for this, compared to the two other public APIs?

This is an evolution, but each api has different applications. I'm looking for feedback on which ones are useful and which ones we can deprecate. Below is a breakdown of the different apis and their strengths and weaknesses.

atomEffect

The first public api for effects in jotai. Returns an atom that runs the effect when it is mounted.

const countAtom = atom(0)
const loggingAtomEffect = atomEffect((get, set) => {
  console.log(`count is now ${get(countAtom}`)
})
// mount directly
store.sub(loggingAtomEffect, () => {})
// - or -
useAtomValue(loggingAtomEffect)
// - or mount by mounting an atom that references the atomEffect)
const derivedAtom = atom((get) => {
  get(loggingAtomEffect)
})
useAtomValue(derivedAtom)

Pros

  • can be conditionally mounted. This is useful when you want to conditionally toggle an effect. I'm not sure how common this is, and this can be achieved from inside the atomEffect just as easily.

Cons

  • needs to be mounted
  • why are atomEffects atoms if they don't hold a value?

withAtomEffect

The next api binds effects to target atoms. withAtomEffect is a functor that adds the atomEffect to a target atom, returning a derived atom that references the target.

const countAtom = atom(0)
const countWithLoggingEffect = withAtomEffect(countAtom, (get, set) => {
  console.log(`count is now ${get(countAtom}`)
})
// mount directly
store.sub(countWithLoggingEffect, () => {})
// - or -
useAtomValue(countWithLoggingEffect)

Pros

  • simplifies binding effects to atoms
  • the effect shares the same lifecycle of the returned atom. This is useful when you want to guarantee an atom effect always runs when the atom updates.

Cons

  • effects can listen to multiple atoms, so binding an effect to a single atom does not always make sense.

useAtomEffect

This is a recipe for creating this hook. It is fairly simple to set up, but I wonder if it should be a public api. Unfortunately, I don't have enough information on how widespread its use it, and if it would be useful to expose as a public api.

The advantage this hook has over useEffect is that it can listen and respond to changes to jotai atoms.

function useCustomHook() {
  useAtomEffect(
    useStableCallback((get, set) => {
      console.log(`count is now ${get(countAtom}`)
    }, [])
  )
  ...
}

Pros

  • can work in component scope (with a stable callback reference)
  • does not require mount logic

Cons

  • Requires a stable reference to the callback
  • Useful in hooks, but cannot be used to register effects outside React
  • Can be confused with useEffect. Their use case is very similar.
  • Have to build the hook from recipe, no public api available at this time

observe

Is this a good idea? I am not sure. But I would love to get more feedback from the community on this PR. I do not plan to merge until I have more data.

observe is designed to run in the global scope, however as long as the callback is memoized it can work in other scopes too. As long as the callback is memoized, observe can invoke inside a component or hook.

Pros

  • can work in global scope and component scope (with a stable callback reference)
  • does not require mount logic

Cons

  • requires a reference to the store if the store is not the default store
  • Yet another api, should we deprecate something?

@dmaskasky dmaskasky force-pushed the observe branch 2 times, most recently from 275410a to 0658276 Compare January 23, 2025 15:35
@dmaskasky dmaskasky force-pushed the main branch 5 times, most recently from 2fee89a to 77dfb20 Compare January 23, 2025 19:54
@dmaskasky dmaskasky force-pushed the observe branch 3 times, most recently from 422cd4d to 77fdf0a Compare January 25, 2025 00:22
@dmaskasky dmaskasky merged commit 0eff05b into main Jan 25, 2025
3 checks passed
@dmaskasky dmaskasky deleted the observe branch January 25, 2025 02:35
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