Skip to main content

Query Atoms

The Atom APIs and Suspense walkthroughs taught how to set an atom instance's promise. But promises themselves innately carry state like:

  • Is the promise resolved?
  • What's the resolved value?
  • If the promise rejected, what was the error?

Rather than translating this "promise state" into state yourself, you can make Zedux do it for you.

you will learn
  • How to create "query atoms"
  • How to get more control with injectPromise()

Query Atoms

To recap the Atom APIs walkthrough: A query atom is just an atom whose state factory returns api(promise).

const fetcherAtom = atom('fetcher', (url: string) => {
const promise = fetch(url).then(result => result.json())

return api(promise)
})

Compared to the fetcherAtom in the suspense walkthrough, this simplified a lot:

  • You no longer have to inject a store and track state manually.
  • You don't have to .setPromise() anymore - the promise passed to api() serves as both the atom's state source and suspense promise.

State Shape

Query atoms have their state set to an object with the following shape:

interface PromiseState<T> {
data?: T
error?: Error
isError: boolean
isLoading: boolean
isSuccess: boolean
status: PromiseStatus
}

type PromiseStatus = 'error' | 'loading' | 'success'
tip

They're called "query atoms" because they mimic React Query queries.

The resolved promise value will be set as the .data property:

function UsersList() {
const { data } = useAtomValue(fetcherAtom, ['/users'])

return (
<ul>
{data.map(user => (
<li key={user}>{user}</li>
))}
</ul>
)
}

Here's the fetcherAtom example from the suspense walkthrough refactored to use query atoms:

Live Sandbox
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// mock stuff for example:
const mockUsers = ['Joe', 'Jim', 'Sally', 'Jill', 'Bob', 'Jamie']
const mockFetch = () =>
new Promise(resolve =>
setTimeout(
() => resolve([...mockUsers].sort(() => Math.random() - 0.5)),
1500
)
)

// start here:
const fetcherAtom = atom('fetcher', (url: string) => {
const promise = mockFetch(url)

return api(promise)
})

function RefreshUsers() {
const instance = useAtomInstance(fetcherAtom, ['/users'], {
suspend: false,
})

return <button onClick={() => instance.invalidate()}>Refresh</button>
}

function UsersList() {
const { data } = useAtomValue(fetcherAtom, ['/users'])

return (
<ul>
{data.map(user => (
<li key={user}>{user}</li>
))}
</ul>
)
}

function Users() {
return (
<>
<Suspense fallback={<div>fetching users...</div>}>
<UsersList />
</Suspense>
<RefreshUsers />
</>
)
}

injectPromise

So far we've been creating the promise inline in the state factory. While this is certainly a supported pattern, there are downsides:

  • A side effect is running immediately when the atom instance is initialized. Since atoms are often initialized during a React render, this means we're kicking off side effects during render 😮. Especially when using SSR, this can be a problem.

  • The fetch will run every time this fetcherAtom instance is reevaluated. In Zedux, you typically do have more control over when evaluations happen than you do over rerenders in React components. But relying on this pattern still makes code more brittle. For example, it's easy for someone to add code later that causes this atom to reevaluate in new cases and voila you have a bug.

We could use injectMemo() to at least prevent the fetch from running unnecessarily on reevaluations.

const promise = injectMemo(() => fetch(url), [url])

But we still have the first problem of the side effect running immediately. A separate injector to isolate these side effects during SSR would be nice. Fortunately there is an injector that solves all of the above: injectPromise().

const fetcherAtom = atom('fetcher', (url: string) => {
const queryApi = injectPromise(
() => fetch(url).then(data => data.json()),
[url]
)

return queryApi
})

injectPromise() gives you the skeleton of a query atom. The first param is a promise factory function that returns your promise. The second param is a dependency array, just like other injectors like injectEffect() and injectMemo(). Zedux will only rerun the promise factory when deps change.

injectPromise() returns an Atom API with:

  • A .store whose state looks like a query atom's state (see the above PromiseState interface).
  • An attached .promise set to the promise returned from your promise factory.

Atom API Composition

The Atom API can then be returned directly from your state factory as your atom's api. Or you can use the returned store and promise however you want - e.g. to compose them together with other stores/promises:

import {
atom,
createStore,
injectMemo,
injectPromise,
injectStore,
} from '@zedux/react'

// an atom that fetches a blog post and all its comments:
const blogPostAtom = atom('blogPost', (id: string) => {
const commentsApi = injectPromise(
() => fetch(`/comments/${id}`).then(data => data.json()),
[id],
// the parent store will subscribe - these child stores don't need to:
{ subscribe: false }
)

const postApi = injectPromise(
() => fetch(`/post/${id}`).then(data => data.json()),
[id],
{ subscribe: false }
)

// compose both stores together:
const store = injectStore()
store.use({ comments: commentsApi.store, post: postApi.store })

const promise = injectMemo(
() => Promise.all([commentsApi.promise, postApi.promise]),
[commentsApi.promise, postApi.promise]
)

return api(store).setPromise(promise)
})
tip

Passing subscribe: false to child stores like this is okay, but not needed. Zedux is intelligent enough to prevent an atom from evaluating multiple times when composed stores update.

However, each subscribed store will add a reason to the EvaluationReason list explaining the next evaluation. Passing subscribe: false is therefore a very tiny micro optimization. Don't use it unless you need it (tip: you probably don't).

Cleanup

Zedux passes an AbortController to the promise factory. Hook into this to cancel fetches or otherwise clean up your async flow.

const fetcherAtom = atom('fetcher', (url: string) => {
const queryApi = injectPromise(
controller =>
fetch(url, { signal: controller.signal }).then(data => data.json()),
[url]
)

return queryApi
})

Just like injectEffect's cleanup, Zedux will abort the signal whenever the promise factory runs again due to deps changing and when the atom instance is destroyed.

Example

Here's injectPromise() plugged into the fetcherAtom example:

Live Sandbox
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// mock stuff for example:
const mockUsers = ['Joe', 'Jim', 'Sally', 'Jill', 'Bob', 'Jamie']
const mockFetch = () =>
new Promise(resolve =>
setTimeout(
() => resolve([...mockUsers].sort(() => Math.random() - 0.5)),
1500
)
)

// start here:
const fetcherAtom = atom('fetcher', (url: string) => {
const queryApi = injectPromise(() => mockFetch(url), [url])

return queryApi
})

function RefreshUsers() {
const [isPending, startTransition] = useTransition()
const instance = useAtomInstance(fetcherAtom, ['/users'], {
suspend: false,
})

return (
<button onClick={() => instance.invalidate()}>
Refresh (does nothing!)
</button>
)
}

function UsersList() {
const { data } = useAtomValue(fetcherAtom, ['/users'])

return (
<ul>
{data.map(user => (
<li key={user}>{user}</li>
))}
</ul>
)
}

function Users() {
return (
<>
<Suspense fallback={<div>fetching users...</div>}>
<UsersList />
</Suspense>
<RefreshUsers />
</>
)
}

Clicking the button now does nothing because injectPromise() prevents the promise from being recreated. Its params ([url]) aren't changing, so Zedux doesn't rerun the promise factory.

challenge!

Try modifying the above sandbox to make the button work again while still using injectPromise() (tip: As with most things in Zedux, there are several ways to go about it).

Multiple Atoms

Managing asynchrony across the atom graph is more verbose in Zedux compared to Recoil and Jotai. This was a conscious decision we made to give you full control over asynchrony. The downside is that you need to exercise that control.

Compare:

// Jotai
const asyncAtom = atom(() => Promise.resolve('hello'))
const derivedAtom = atom(async get => (await get(asyncAtom)).toUpperCase())

// Zedux
const asyncAtom = atom('async', () => api(Promise.resolve('hello')))
const derivedAtom = ion('derived', ({ get }) =>
get(asyncAtom).data?.toUpperCase()
)

While this isn't so bad so far, in Zedux, consumers of derivedAtom will need to handle undefined until the data comes in.

When forwarding the promise, you can let React suspense take care of that in components:

const derivedAtom = ion('derived', ({ get, getInstance }) => {
const asyncInstance = getInstance(asyncAtom)
const { data } = get(asyncInstance)

// forward the promise:
return api(data?.toUpperCase()).setPromise(asyncInstance.promise)
})

function MyComponent() {
// this component will suspend until this string is defined
const uppercaseString = useAtomValue(derivedAtom)
}

Why Regress?

In Recoil and Jotai, you "don't need to worry" about asynchrony like this. The atom graph takes care of it - auto-awaiting values wherever needed inside atoms themselves. This certainly seems like Zedux is taking a step backwards.

An early prototype of Zedux implemented Recoil-esque promise handling for atoms. We got rid of it because we needed more control over async flows. With Recoil, you have to learn their promise helpers like waitForAll(), waitForAny(), etc. With Zedux, you can use absolutely anything.

const derivedAtom = ion('derived', ({ get, getInstance }) => {
const asyncInstance = getInstance(asyncAtom)
const store = injectStore('')

injectEffect(() => {
// use RxJS's `from` to convert a store to an observable of state updates:
const subscription = from(asyncInstance.store)
.pipe(throttleTime(1000))
.subscribe(state => store.setState(state.data?.toUpperCase()))

return () => subscription.unsubscribe()
}, [asyncInstance])

return store
})

This is very verbose, but the beauty of Zedux's model is that, just like React hooks, all of this can be abstracted into a custom injector. The above code could be reduced to something like this:

const derivedAtom = ion('derived', () =>
injectThrottle(asyncAtom, state => state.data?.toUpperCase())
)

Remember that Zedux was designed for Ultimate Power. We do some crazy stuff in our fintech apps at Omnistac. Some design decisions like this might seem annoying for smaller apps, but just remember that they scale very well.

For more info, see this discussion thread.

Not React Query

If you know React Query, you've surely noticed that these "query atoms" lack tons of features that React Query has. This is primarily because Zedux's objective is different from React Query's: React Query is a server cache manager. Zedux is good at managing server caches and is extensible enough to at least match React Query in that regard, but Zedux is primarily a client state manager like Recoil and Redux.

At Omnistac, we use lots of sockets and observables to shuttle highly-volatile state around. Zedux is specifically designed for this use case, so may even be the better server cache manager for similar apps.

While we could add pagination, infinite query helpers, window focus refetching, etc, etc to Zedux core, it would bloat the codebase significantly. Query atoms should be sufficient promise handling for most apps. For everything else, we may create a @zedux/query package someday that adds injectors/helpers for more niche query operations. In the meantime:

You can use both. In fact we designed Zedux in a way that allowed us to dual-wield Redux and Zedux for years. It's easily possible to provide a QueryClient to an ecosystem via ecosystem context or creating a reactQueryAtom.

Recap

  • Create query atoms with return api(promise)
  • Gain more control over query atom state with injectPromise().

Next Steps

Atoms drive Zedux's powerful DI model, and stores are the backbone of atoms. It's time to learn all about the Zedux store.