atom.io
overview
atom.io
is a data management library for TypeScript with the following goals:
- Laziness: only recompute values that are being observed, whose dependencies have changed
- Testability: write unit tests for your store without mocking
- Portability: run your store in the browser, on the server, or in a web worker
- Batteries Included: solve common use cases like transactions and time travel
- Composability: build your store out of small, reusable pieces. Declare them anywhere, import them where you need them, and organize later
package contents
Exports | Description |
---|---|
atom | Declare a reactive variable. |
selector | Declare a reactive variable derived from other reactive variables. |
atomFamily | Compose a function that can create reactive variables of a single type dynamically. |
selectorFamily | Compose a function that can create reactive variables derived from other reactive variables dynamically. |
transaction | Declare a function that can batch multiple atom changes into a single update. |
timeline | Track the history of a group of reactive variables. |
subscribe | Subscribe to a reactive variable, calling a callback whenever it is updated. |
getState | Get the value of a reactive variable. If the reactive variable is a selector, the value is derived from other reactive variables. |
setState | Set the value of a reactive variable. If the reactive variable is a selector, the value is derived from other reactive variables. |
Silo | An isolated store with all of the above functions bound to it. Useful for testing. |
atom
import { atom } from "atom.io"
export const countState = atom<number>({
key: `count`,
default: 0,
})
Imagine an atom
as a "reactive variable," with a key, a type, and a default value.
import { getState } from "atom.io"
import { countState } from "./declare-an-atom"
countState // -> { key: `count`, type: `atom` }
getState(countState) // -> 0
getState({ key: `count`, type: `atom` }) // -> 0
As you can see, what is returned from atom
does not contain the value itself.
Instead, it returns an importable, serializable, and replaceable reference to the value.
We call this an AtomToken
. In this case, an AtomToken<number>
.
import { getState, setState } from "atom.io"
import { countState } from "./declare-an-atom"
getState(countState) // -> 0
setState(countState, 1)
getState(countState) // -> 1
// @ts-expect-error `hello` is not a number
setState(countState, `hello`)
An atom's value is accessed by calling getState
and setState
with the atom's token.
TypeScript will discourage you from setting the wrong type of value.
import { subscribe } from "atom.io"
import { countState } from "./declare-an-atom"
subscribe(countState, (count) => {
console.log(`count is now ${count.newValue}`)
})
Unlike a standard variable, you can subscribe
to an atom. The callback you pass
to the subscription will be called whenever the atom is set to a new value.
import { useO } from "atom.io/react"
import { countState } from "./declare-an-atom"
export default function Component(): JSX.Element {
const count = useO(countState)
return <>{count}</>
}
This is an example of the observer pattern. Following the observer pattern allows atom.io to easily integrate with an observer like React. More on this later.
selector
import { atom, selector } from "atom.io"
export const dividendState = atom<number>({
key: `dividend`,
default: 0,
})
export const divisorState = atom<number>({
key: `divisor`,
default: 2,
})
export const quotientState = selector<number>({
key: `quotient`,
get: ({ get }) => {
const dividend = get(dividendState)
const divisor = get(divisorState)
return dividend / divisor
},
})
A selector is also a reactive variable, but its value is derived from other atoms or selectors.
import { getState, setState } from "atom.io"
import { dividendState, divisorState, quotientState } from "./declare-a-selector"
getState(dividendState) // -> 0
getState(divisorState) // -> 2
getState(quotientState) // -> 0
setState(dividendState, 4)
getState(quotientState) // -> 2
In this example, we can see that by setting dividendState
to a new value, the value of quotientState
is automatically updated.
families
Sometimes you need a lot of the same type of atom or selector. The atomFamily
and selectorFamily
functions provide a convenient interface for declaring states dynamically.
import type { RegularAtomToken } from "atom.io"
import { atomFamily, getState } from "atom.io"
import { useO } from "atom.io/react"
export const xAtoms = atomFamily<number, string>({
key: `x`,
default: 0,
})
export const yAtoms = atomFamily<number, string>({
key: `y`,
default: 0,
})
getState(xAtoms, `example`) // -> 0
export function Point(props: {
xState: RegularAtomToken<number>
yState: RegularAtomToken<number>
}): JSX.Element {
const x = useO(props.xState)
const y = useO(props.yState)
return <div className="point" style={{ left: x, top: y }} />
}
For example, maybe we're making an app with Point
s laid out in two dimensions.
We might use an atomFamily
to handle creating state for each node. Or, better yet, we might make two families—for each node's x and y coordinates.
Counterintuitively, it is likely a performance win in highly interactive applications to take the latter approach, because when nodes move, we only need to replace two primitives in the underlying map, rather than a whole object.
This is the key to high-performance interactivity in atom.io: the smaller the state, the better.
If you want to update your states frequently, keep state primitive.
import { atom } from "atom.io"
import { useO } from "atom.io/react"
import { findState } from "~/packages/atom.io/ephemeral/src"
import { Point, xAtoms, yAtoms } from "./declare-a-family"
export const pointIndex = atom<string[]>({
key: `pointIndex`,
default: [],
})
export function AllPoints(): JSX.Element {
const pointIds = useO(pointIndex)
return (
<>
{pointIds.map((pointId) => {
const xAtom = findState(xAtoms, pointId)
const yAtom = findState(yAtoms, pointId)
return <Point key={pointId} xState={xAtom} yState={yAtom} />
})}
</>
)
}
In this example, we use a single atom<string[]>
to track the members of our family.
It is up to you to decide how to track the members of families you create. atom.io
does not do this, because different sorts of collections have different performance characteristics. There is no one-size-fits-all solution.
Keen readers may recognize that collections generally extend Object
, and that Object
is not primitive. If you use a lot of collections in your store, or your collections change frequently, you may consider using mutable
atoms for them. More on this in the advanced section.
transaction
Transactions allow you to batch multiple atom changes into a single update. This is useful for validating a complex set of changes before it is applied to the store.
import { atom, atomFamily, transaction } from "atom.io"
export type PublicUser = {
id: string
displayName: string
}
export const publicUserAtoms = atomFamily<PublicUser, string>({
key: `publicUser`,
default: (id) => ({ id, displayName: `` }),
})
export const userIndex = atom<string[]>({
key: `userIndex`,
default: [],
})
export const addUserTX = transaction<(user: PublicUser) => void>({
key: `addUser`,
do: ({ get, set }, user) => {
set(publicUserAtoms, user.id, user)
if (!get(userIndex).includes(user.id)) {
set(userIndex, (current) => [...current, user.id])
}
},
})
A common use case is creating some new state using a family and adding it to an index tracking members of that family.
import { atom, atomFamily, selectorFamily, transaction } from "atom.io"
export const nowState = atom<number>({
key: `now`,
default: Date.now(),
effects: [
({ setSelf }) => {
const interval = setInterval(() => {
setSelf(Date.now())
}, 1000)
return () => {
clearInterval(interval)
}
},
],
})
export const timerIndex = atom<string[]>({
key: `timerIndex`,
default: [],
})
export const findTimerStartedState = atomFamily<number, string>({
key: `timerStarted`,
default: 0,
})
export const findTimerLengthState = atomFamily<number, string>({
key: `timerLength`,
default: 60_000,
})
const findTimerRemainingState = selectorFamily<number, string>({
key: `timerRemaining`,
get:
(id) =>
({ get }) => {
const now = get(nowState)
const started = get(findTimerStartedState, id)
const length = get(findTimerLengthState, id)
return Math.max(0, length - (now - started))
},
})
export const addOneMinuteToAllRunningTimersTX = transaction({
key: `addOneMinuteToAllRunningTimers`,
do: ({ get, set }) => {
const timerIds = get(timerIndex)
for (const timerId of timerIds) {
if (get(findTimerRemainingState, timerId) > 0) {
set(findTimerLengthState, timerId, (current) => current + 60_000)
}
}
},
})
In this example, we add a minute to all running timers.
import { atom, atomFamily, runTransaction, transaction } from "atom.io"
import type { Loadable } from "atom.io/data"
export type GameItems = { coins: number }
export type Inventory = Partial<Readonly<GameItems>>
export const myIdState = atom<Loadable<string>>({
key: `myId`,
default: async () => {
const response = await fetch(`https://io.fyi/api/myId`)
const { id } = await response.json()
return id
},
})
export const playerInventoryAtoms = atomFamily<Inventory, string>({
key: `inventory`,
default: {},
})
export const giveCoinsTX = transaction<
(playerId: string, amount: number) => Promise<void>
>({
key: `giveCoins`,
do: async ({ get, set }, playerId, amount) => {
const myId = await get(myIdState)
const myInventory = get(playerInventoryAtoms, myId)
if (!myInventory.coins) {
throw new Error(`Your inventory is missing coins`)
}
const myCoins = myInventory.coins
if (myCoins < amount) {
throw new Error(`You don't have enough coins`)
}
const theirInventory = get(playerInventoryAtoms, playerId)
const theirCoins = theirInventory.coins ?? 0
set(playerInventoryAtoms, myId, (previous) => ({
...previous,
coins: myCoins - amount,
}))
set(playerInventoryAtoms, playerId, (previous) => ({
...previous,
coins: theirCoins + amount,
}))
},
})
;async () => {
try {
await runTransaction(giveCoinsTX)(`playerId`, 3)
} catch (thrown) {
if (thrown instanceof Error) {
alert(thrown.message)
}
}
}
If a transaction throws, the state of the store is not changed. However, it is up to you to handle the error.
timeline
Timelines allow you to track the history of a group of atoms. If these atoms are set, or set as a group by a selector or transaction, the timeline will record the changes. A timeline can be used to undo and redo changes.
import { timeline } from "atom.io"
import { xAtoms, yAtoms } from "../families/declare-a-family"
export const coordinatesTL = timeline({
key: `timeline`,
scope: [xAtoms, yAtoms],
})
In this example, we create a timeline that tracks the history of two families of atoms.
import { setState, subscribe } from "atom.io"
import { xAtoms } from "../families/declare-a-family"
import { coordinatesTL } from "./create-a-timeline"
subscribe(coordinatesTL, (value) => {
console.log(value)
})
setState(xAtoms, `sample_key`, 1)
/* {
newValue: 1,
oldValue: 0,
key: `sample_key`,
type: `atom_update`,
timestamp: 1629780000000,
family: {
key: `x`,
type: `atom_family`,
}
} */
In this example, we subscribe to the timeline. Above are the structures of timeline updates.
import { getState, redo, setState, subscribe, undo } from "atom.io"
import { xAtoms } from "../families/declare-a-family"
import { coordinatesTL } from "./create-a-timeline"
subscribe(coordinatesTL, (value) => {
console.log(value)
})
setState(xAtoms, `sample_key`, 1)
getState(xAtoms, `sample_key`) // 1
setState(xAtoms, `sample_key`, 2)
getState(xAtoms, `sample_key`) // 2
undo(coordinatesTL)
getState(xAtoms, `sample_key`) // 1
redo(coordinatesTL)
getState(xAtoms, `sample_key`) // 2
In this example, we undo and redo changes to the timeline.
advanced
async
Often, state is not immediately available. For example, if you are fetching data from a server, you might use the fetch function atom.io
offers natural support for Promise
and async/await
patterns.
import http from "node:http"
import { atom, getState } from "atom.io"
import type { Loadable } from "atom.io/data"
const server = http.createServer((req, res) => {
let data: Uint8Array[] = []
req
.on(`data`, (chunk) => data.push(chunk))
.on(`end`, () => {
res.writeHead(200, { "Content-Type": `text/plain` })
res.end(`The best way to predict the future is to invent it.`)
data = []
})
})
server.listen(3000)
export const quoteState = atom<Loadable<Error | string>>({
key: `quote`,
default: async () => {
try {
const response = await fetch(`http://localhost:3000`)
return await response.text()
} catch (thrown) {
if (thrown instanceof Error) {
return thrown
}
throw thrown
}
},
})
void getState(quoteState) // Promise { <pending> }
await getState(quoteState) // "The best way to predict the future is to invent it."
void getState(quoteState) // "The best way to predict the future is to invent it."
Loadable
is a shorthand that means "sometimes this is a Promise
". This is really useful, because await
is harmless if the value is not a Promise. When the Promise does resolve, the value is set into the value map, allowing for maximum versatility in Suspenseful environments.
import { atom, selector } from "atom.io"
import type { Loadable } from "atom.io/data"
function discoverCoinId() {
const urlParams = new URLSearchParams(window.location.search)
return urlParams.get(`coinId`) ?? `bitcoin`
}
export const coinIdState = atom<string>({
key: `coinId`,
default: discoverCoinId,
effects: [
({ setSelf }) => {
window.addEventListener(`popstate`, () => {
setSelf(discoverCoinId())
})
},
],
})
export const findCoinPriceState = selector<Loadable<number>>({
key: `coinPrice`,
get: async ({ get }) => {
const coinId = get(coinIdState)
const response = await fetch(
`https://api.coingecko.com/api/v3/coins/${coinId}`,
)
const json = await response.json()
return json.market_data.current_price.usd
},
})
Here is an example where get a query parameter from the URL, then use it to fetch some data from a server. This is a great pattern, because our selector's value will be cached as long as the URL parameter does not change.
import { atom, getState, setState } from "atom.io"
import type { Loadable } from "atom.io/data"
export const nameState = atom<Loadable<string>>({
key: `name`,
default: ``,
})
// resolve in 2 seconds
setState(
nameState,
new Promise<string>((resolve) =>
setTimeout(() => {
resolve(`one`)
}, 2000),
),
)
// resolve in 1 second
setState(
nameState,
new Promise<string>((resolve) =>
setTimeout(() => {
resolve(`two`)
}, 1000),
),
)
// "two" resolves first
// promise for "one" is set to be ignored
// "one" resolves, but is ignored
await new Promise((resolve) => setTimeout(resolve, 3000))
void getState(nameState) // "two"
In the case that we update an async state more quickly than the promises are resolved, only the last promise's resolved value will be set into the state. All previous results will be discarded.