Skip to main content

Atom APIs

The quick start showed that injectors are like hooks for atoms. But some operations don't fit well into the injector paradigm. Injectors, like hooks, should be composable and usable multiple times throughout an atom state factory and other injectors.

But atoms have a few one-off operations (operations that should only be performed once). For these, we use an Atom API.

you will learn:

How to create an Atom API and use it to:

  • Export anything from an atom
  • Kick off React suspense
  • Make Zedux control promise state

api()

To create an Atom API, simply wrap state in a call to api() before returning it from a state factory:

import { api, atom } from '@zedux/react'

const exampleAtom = atom('example', () => 'initial state') // before
const exampleAtom = atom('example', () => api('initial state')) // after

This works with stores too:

const exampleAtom = atom('example', () => {
const store = injectStore()

return store // before
return api(store) // after
})

You can create an Atom API anywhere, but only the Atom API returned from the state factory will become the API of the atom.

So now then ... what can an Atom API do?

Exports

Atoms can export anything via a returned Atom API:

Live Sandbox
12345678910111213141516171819202122
const counterAtom = atom('counter', () => {
const store = injectStore(0)

// Expose methods!
return api(store).setExports({
decrement: () => store.setState(state => state - 1),
increment: () => store.setState(state => state + 1),
})
})

function App() {
const { decrement, increment } = useAtomInstance(counterAtom).exports
const value = useAtomValue(counterAtom)

return (
<>
<div>Counter: {value}</div>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</>
)
}

Atom APIs have a .setExports() method that defines an atom instance's exports (when the atom API is returned from the state factory). These exports are set on initial evaluation and ignored on evaluations after that.

The exports are set on instance.exports. Wherever you have an atom instance, you have access to its exports.

Exports must be an object and can contain anything. As you can imagine, exports are another big feature that sets Zedux apart from other atomic libraries.

important

Exports are constant! This means exported variables should be stable references. Zedux won't update them on subsequent evaluations. In this example, increment and decrement are not stable references, but they don't reference anything except the store, which is stable. You have to be conscious of this.

tip

Think of atom exports just like module exports in JavaScript files. You never change a file's exports. There's no need to! You can, however, mutate objects and change state in the file using exported functions. That's exactly what you can do in atoms with injected refs and stores.

Exports are extremely powerful. Exposing an atom's "public API" like this is just one of their uses.

Exporting Refs

Since you can export anything, refs can be used to great effect:

const gridAtom = atom('grid', () => {
const gridApiRef = injectRef()

// this atom provides universal access to the grid api once it's been set:
return api().setExports({ gridApiRef })
})

function Grid() {
const { gridApiRef } = useAtomInstance(gridAtom).exports

// expose this grid component's api to the atom universe!
return <ThirdPartyGridLibrary onLoad={api => (gridApiRef.current = api)} />
}
tip

injectRef() is the injector equivalent of useRef(). Exported refs can be used exactly as React refs!

Exporting Callbacks

Another use is to export callbacks for implementing render-as-you-fetch patterns:

Live Sandbox
1234567891011121314151617181920212223242526272829303132333435363738
// some mocks for this example:
const mockFetch = data =>
new Promise(resolve => {
setTimeout(() => resolve(data), 1000)
})

// start here:
const userNameAtom = atom('userName', () => {
const store = injectStore({
isLoading: false,
name: 'Joe',
})

const toggleUser = injectCallback(() => {
const { isLoading, name } = store.getState()
if (isLoading) return

store.setStateDeep({ isLoading: true })

mockFetch(name === 'Joe' ? 'Bob' : 'Joe').then(val =>
store.setState({ isLoading: false, name: val })
)
}, [])

return api(store).setExports({ toggleUser })
})

function Age() {
const { toggleUser } = useAtomInstance(userNameAtom).exports
const { isLoading, name } = useAtomValue(userNameAtom)

return (
<>
<div>Current User's Name: {isLoading ? 'Loading...' : name}</div>
<button onClick={toggleUser}>Change User</button>
</>
)
}
tip

injectCallback is the injector equivalent of useCallback. We used it here with empty deps ([]) to remind us that this is an export and its reference can't change. This means we can't use any dynamic values inside the callback - only stable references. injectCallback also automatically batches all state updates in the callback.

This is poor man's async handling. Let's upgrade to Suspense.

Suspense

Atoms can be used as suspending resources in React. To suspend, simply pass a promise to a returned Atom API's .setPromise() method:

return api(val).setPromise(suspensePromise)

An atom that returns such an api will cause React to suspend until the promise resolves.

Live Sandbox
12345678910111213141516171819202122232425262728293031323334
const mockFetch = data =>
new Promise(resolve => {
setTimeout(() => resolve(data), 1000)
})

const ageAtom = atom('age', () => {
const store = injectStore(undefined, { subscribe: false })

// An inline side effect?? We'll cover this in the suspense walkthrough
const promise = mockFetch(Math.floor(Math.random() * 100))
promise.then(val => store.setState(val))

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

function Age() {
const instance = useAtomInstance(ageAtom)
const age = useAtomValue(ageAtom)

return (
<>
<div>The Time Traveller's Age: {age}</div>
<button onClick={() => instance.invalidate()}>Refresh</button>
</>
)
}

function App() {
return (
<Suspense fallback={<div>Fetching Age...</div>}>
<Age />
</Suspense>
)
}
note

Unlike exports, the promise can change! Be sure to return a stable reference if you don't want the atom instance to kick off suspense again on reevaluations.

We'll cover much more about suspense in the suspense walkthrough.

Query Atoms

You can also pass a promise directly to api(). This tells Zedux to take control over the atom's state and update it based on the promise. The state shape is similar to React Query queries. Because of this, we call such atoms "query atoms":

Live Sandbox
12345678910111213141516171819202122232425262728293031323334
const mockFetch = data =>
new Promise(resolve => {
setTimeout(() => resolve(data), 1000)
})

const ageAtom = atom('age', () => {
const promise = mockFetch(Math.floor(Math.random() * 100))

// just pass the promise!
return api(promise)
})

function Age() {
const instance = useAtomInstance(ageAtom)

// the promise result will be set as the state's `data` property.
// You should recognize this if you know React Query.
const { data } = useAtomValue(ageAtom)

return (
<>
<div>The Time Traveller's Age: {data}</div>
<button onClick={() => instance.invalidate()}>Refresh</button>
</>
)
}

function App() {
return (
<Suspense fallback={<div>Fetching Age...</div>}>
<Age />
</Suspense>
)
}
why can't I return a promise without using api()?

In Zedux, atoms always evaluate synchronously. This allows Zedux's DI to be as dynamic as possible and simplifies a lot for you. It also means that state factories can't be asynchronous functions.

Since returning a promise and declaring an async function are essentially the same thing (especially in Typescript's eyes), we found it better to disallow returning a promise directly. Just wrap your promise in api(promise).

Query atoms get their own walkthrough.

Properties

The object returned from api() is an instance of the AtomApi class. It has a few readonly properties that reference the values you set on it:

  • .value - The value passed to api().
  • .exports - The exports set via api().setExports() and api().addExports().
  • .promise - The promise set via api().setPromise().
  • .store - The store passed to api(). If a non-store value was passed, this is undefined.
const myApi = api(myStore).setExports(myExports).setPromise(myPromise)

myApi.value === myStore
myApi.exports === myExports
myApi.promise === myPromise
myApi.store === myStore

Atom API Composition

An Atom API can be cloned by passing it to api().

import { api } from '@zedux/react'

const myApi = api('some state').setExports({ something })
const clonedApi = api(myApi) // has the same value and exports

Atom APIs are a convenient way to pass stores, exports, and promises around. You may find yourself working with multiple Atom APIs when using custom injectors.

Atom APIs have a .addExports() method specifically for merging exports together from multiple sources:

const complexAtom = atom('complex', () => {
// say these two injectors return AtomApis:
const apiA = injectA()
const apiB = injectB()

// say we want to expose apiA's state (or store) as the state of this atom,
// but we want the exports from both APIs:
return api(apiA).addExports(apiB.exports) // merge apiB's exports into apiA's
})

If you have multiple Atom APIs with stores attached, you can also use store composition to create a new Atom API from the injected APIs:

const apiA = injectA()
const apiB = injectB()

const composedStore = injectStore()
composedStore.use({ a: apiA.store, b: apiB.store })

return api(composedStore).setExports({
...apiA.exports,
...apiB.exports,
})

Promises

Unlike Recoil, Zedux doesn't provide any promise utilities like waitForAll/waitForNone. This is because Zedux relinquishes all control over async flows to you, the programmer. This means you can use native Promise APIs or any other async tool (read RxJS) to control async flows.

Let's look at an example using Promise.all() to "compose" promises together from multiple sources:

Live Sandbox
12345678910111213141516171819202122232425262728293031323334353637383940
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))

const oneSecondAtom = atom('oneSecond', () =>
api('got one!').setPromise(wait(1000))
)

const twoSecondsAtom = atom('twoSeconds', () =>
api('got two!').setPromise(wait(2000))
)

const bothAtom = atom('both', () => {
const instance1 = injectAtomInstance(oneSecondAtom)
const instance2 = injectAtomInstance(twoSecondsAtom)

// memoize to only create a new promise when dependency promises change
const promise = injectMemo(
() => Promise.all([instance1.promise, instance2.promise]),
[instance1.promise, instance2.promise]
)

return api('got both!').setPromise(promise)
})

const One = () => <div>{useAtomValue(oneSecondAtom)}</div>
const Two = () => <div>{useAtomValue(twoSecondsAtom)}</div>
const Both = () => <div>{useAtomValue(bothAtom)}</div>

const App = () => (
<>
<Suspense fallback={<div>Waiting for #1</div>}>
<One />
</Suspense>
<Suspense fallback={<div>Waiting for #2</div>}>
<Two />
</Suspense>
<Suspense fallback={<div>Waiting for both</div>}>
<Both />
</Suspense>
</>
)
tip

Click "Reset" in the above sandbox to see the promises cascade.

Recap

  • Create an Atom API with api(stateOrStore). Return an Atom API from a state factory to make it the API of the atom.
  • Use .setExports() to "export" an object to the instance's .exports property.
  • Make atoms trigger React suspense with .setPromise(myPromise).
  • Make Zedux control promise state by returning api(myPromise).
  • Clone Atom APIs with api(myApi).

Next Steps

You've now unlocked some of the stronger powers of Zedux atoms. It's time to zoom out and look at the bigger picture.