atom.io

overview

atom.io is a data management library for TypeScript with the following goals:

  1. Laziness: only recompute values that are being observed, whose dependencies have changed
  2. Testability: write unit tests for your store without mocking
  3. Portability: run your store in the browser, on the server, or in a web worker
  4. Batteries Included: solve common use cases like transactions and time travel
  5. Composability: build your store out of small, reusable pieces. Declare them anywhere, import them where you need them, and organize later

package contents

ExportsDescription
atomDeclare a reactive variable.
selectorDeclare 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.

timelineTrack 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

declare-an-atom.ts
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.

an-atom-token-is-a-reference.ts
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>.

get-and-set-an-atom.ts
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.

subscribe-to-an-atom.ts
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.

subscribe-is-the-foundation-of-reactivity.tsx
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

declare-a-selector.ts
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.

use-a-selector.ts
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.

declare-a-family.tsx
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 Points 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.

use-an-index-to-track-family-members.tsx
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.

use-a-family-in-a-transaction.ts
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.

iterate-through-an-index-changing-the-value-of-some-atoms.ts
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.

try-catch-a-failed-transaction.ts
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.

create-a-timeline.ts
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.

subscribe-to-a-timeline.ts
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.

undo-and-redo-changes.ts
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.

await-your-state.ts
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.

loadable-selector.ts
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.

avoid-race-between-promises.ts
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.