Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat: implement scoped atoms, inject, and ecosystem.withScope #172

Merged
merged 5 commits into from
Feb 11, 2025

Conversation

bowheart
Copy link
Collaborator

@bowheart bowheart commented Feb 10, 2025

@affects atoms, react, stores

Description

I spent a few days exploring the inject utility discussed in #129. I was mostly just experimenting but I somehow actually knocked the whole thing out. It is pretty complex but so powerful that I really want to start using it now 💪 . So here we are.

Implement inject. This is a new injector that is a thin wrapper around React's use util (hence the name) but with a few key differences:

  • inject does not support promises. It will if we ever introduce async atoms (which are currently waiting on the TC39 async context proposal).
  • inject accepts atom templates and will pull a provided instance from <AtomProvider>.
  • inject should not be used conditionally, though it can be used in loops, unlike normal injectors, as long as a given context is passed to inject at least once on every evaluation. inject calls should also, preferably, not be reordered, as that could result in ids generating with those values reordered in rare edge cases (extremely rare - you'd have to reset the ecosystem then restore some value that depended on the old id. Not likely to ever happen). If inject is used conditionally, Zedux might not capture a contextual value that is actually required.
  • we can provide contextual values to scoped atoms (including those using React context) outside of React

Implement ecosystem.withScope, a new utility that accepts a scope and a callback function and runs the callback function in a scoped context with the passed scope. This is recursive - any ecosystem.withScope calls inside an existing scope will recursively look up through all scopes to find a provided context.

Scope

This is a new concept in Zedux. Some definitions:

  • "scope" - a set of contextual values. Specifically, this is a JS Map that maps "contexts" to their provided values.
  • "scoped context" - a function evaluation that has access to atom scope.
  • "contextual value" - a provided value of a given context.
  • "context" - can mean one of two completely different things:
    • a function evaluation (as in "scoped context")
    • a stable object reference (as in "contextual value". This is a React context object or an atom template returned from the atom() factory)

Examples

Scope in React:

import { createContext } from 'react'
import { AtomProvider, atom, inject, useAtomInstance, useAtomValue } from '@zedux/react'

// it's highly recommended to make React contexts default to `undefined` so
// Zedux detects them as unprovided. This is just good practice in general
const reactContext = createContext<string | undefined>(undefined)
const contextAtom = atom('context', () => 'atom context')

const scopedAtom = atom('scoped', () => {
  return `${inject(reactContext)} and ${inject(contextAtom)}`
})

function Child() {
  const scopedValue = useAtomValue(scopedAtom)

  return <div>{scopedValue}</div> // "react context and atom context"
}

function Parent() {
  const contextInstance = useAtomInstance(contextAtom)

  return (
    <reactContext.Provider value="react value">
      <AtomProvider instance={contextInstance}>
        <Child />
      </AtomProvider>
    </reactContext.Provider>
  )
}

This is typically how scoped atoms should be consumed. When used this way, you basically don't have to think about it. Scopes "just work". Zedux will throw an error if you forget to provide a contextual value.

Scopes outside React:

// reusing the atoms and React context from the previous example:
const contextNode = ecosystem.getNode(contextAtom)

// a scope just maps context objects to their provided values:
const scope = new Map([[reactContext, 'react value'], [contextAtom, contextNode]])

// get the scopedAtom in a scoped context:
const scopedNode = ecosystem.withScope(scope, () => ecosystem.getNode(scopedAtom))

Extra Considerations

Some things we may want to address later.

String hash vs WeakMap

Scoped atoms get their scope as part of their id. This currently uses Zedux's React Query-esque key hashing algorithm. While this is very convenient for DX, it may be preferable to create an id for every provided object reference we encounter (using the ecosystem's existing baseKeys WeakMap) for performance reasons. This is a candidate for an ecosystem config option so you can hash in dev, map in prod. Though note that they have slightly different behavior:

  • When hashing, it doesn't matter if object references match; Zedux only detects when keys or values change.
  • When using the WeakMap, object reference is all that matters.

Implicit Dependencies

Scopes implicitly change the behavior/needs of the atom they're scoping. This is why we need inject to not be called conditionally - so Zedux can figure out everything that's implicitly needed while we have scope, so we can use those values when we're no longer in a scoped context.

Rather than putting this no-conditional-calls requirement on inject, we could give atoms the ability to define their scope explicitly, up-front as a config option passed to atom()/similar:

const scopedAtom = atom('scoped', () => {
  return `${inject(reactContext)} and ${inject(contextAtom)}`
}, {
  scope: [reactContext, contextAtom]
})

And Zedux would always capture those contextual values regardless of what inject actually uses.

Scope Propagation

Scoped nodes propagate their scope to all observers. This makes scopes kind of a leaky abstraction - scoped nodes "leak" scope into everything that uses them. There are maybe helper APIs we could introduce to make working with multiple scopes easier.

Hydration

Scoped atoms can't be hydrated. They shouldn't need to be - their whole purpose is to pull values from a current, live scope.

While we could make it so scoped atoms are given an initial hydration (by making scopes explicit, see above), it would be counterproductive: The primary purpose of hydration is a page load speed boost. Hydrated scoped atoms would always make initial load slower since they must evaluate once before we know their scope anyway, then we'd find the hydration, hydrate, and reevaluate. While that's all fast, the tiny bit of extra overhead means hydrating scoped atoms will just never be good.

We should add a way to easily filter out scoped atoms when dehydrating the ecosystem.

Don't hydrate scoped atoms; hydrate the scope. I'll make sure this is documented.

@bowheart bowheart mentioned this pull request Feb 11, 2025
53 tasks
@bowheart bowheart merged commit 74938a2 into master Feb 11, 2025
2 checks passed
@bowheart bowheart deleted the josh/inject branch February 11, 2025 14:54
@marbemac
Copy link
Contributor

👏 wowwww, dope! v2 is looking spicy 🌶️

@bowheart bowheart added this to the Zedux v2 milestone Feb 11, 2025
# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants