Skip to main content

Installation

npm install @effect-atom/atom-react voltaire-effect effect

Setup

Wrap your app with RegistryProvider:
import { RegistryProvider } from "@effect-atom/atom-react"

function App() {
  return (
    <RegistryProvider>
      <YourApp />
    </RegistryProvider>
  )
}

Core Concepts

effect-atom provides React integration for Effect.ts:
  • Atoms - Observable state containers that can wrap Effects
  • Result - Loading/Success/Failure monad for async state
  • Hooks - useAtomValue, useAtomSet, useAtom for React
  • Layer integration - Atom.runtime() to use Effect services

Basic Example: Display Balance

import { Atom, useAtomValue, Result } from "@effect-atom/atom-react"
import { Layer } from "effect"
import { getBalance, Provider, HttpTransport } from "voltaire-effect"

// 1. Compose layers first, then create runtime
const ProviderLayer = Provider.pipe(
  Layer.provide(HttpTransport("https://eth.llamarpc.com"))
)
const runtime = Atom.runtime(ProviderLayer)

// 2. Create an atom that fetches balance
const balanceAtom = (address: `0x${string}`) =>
  runtime.atom(getBalance(address, "latest"))

// 3. Use in React component
function Balance({ address }: { address: `0x${string}` }) {
  const result = useAtomValue(balanceAtom(address))

  return Result.builder(result)
    .onInitial(() => <div>Loading...</div>)
    .onSuccess((balance) => (
      <div>{(Number(balance) / 1e18).toFixed(4)} ETH</div>
    ))
    .onFailure((cause) => (
      <div>Error: {String(cause)}</div>
    ))
    .render()
}

Result Monad

Result has three states for handling async data:
StateDescription
InitialEffect hasn’t started or is loading
Success<A>Has value, optional waiting flag for refresh
Failure<E>Has error cause
import { Result } from "@effect-atom/atom-react"

Result.builder(result)
  .onInitial(() => <Skeleton />)
  .onSuccess((data) => <DataView data={data} />)
  .onSuccessWaiting((data) => <DataView data={data} refreshing />)
  .onFailure((cause) => <ErrorView error={cause} />)
  .render()

Wallet Connection Example

import { Atom, useAtom, useAtomValue, Result } from "@effect-atom/atom-react"
import { Effect, Layer } from "effect"
import { getBalance, getBlockNumber, Provider, HttpTransport } from "voltaire-effect"

// Compose layers once, create runtime at module level
const ProviderLayer = Provider.pipe(
  Layer.provide(HttpTransport(process.env.NEXT_PUBLIC_RPC_URL!))
)
const runtime = Atom.runtime(ProviderLayer)

// Connected address (writable atom)
const addressAtom = Atom.make<`0x${string}` | null>(null)

// Balance (derived, refetches when address changes)
const balanceAtom = Atom.make((get) => {
  const address = get(addressAtom)
  if (!address) return Effect.succeed(null)
  return get.result(runtime.atom(getBalance(address, "latest")))
})

// Block number (auto-refreshes on window focus)
const blockNumberAtom = runtime.atom(getBlockNumber()).pipe(
  Atom.refreshOnWindowFocus,
  Atom.keepAlive
)

function ConnectButton() {
  const [address, setAddress] = useAtom(addressAtom)

  const connect = async () => {
    const accounts = await window.ethereum.request({
      method: "eth_requestAccounts"
    })
    setAddress(accounts[0])
  }

  if (address) {
    return <div>Connected: {address.slice(0, 6)}...</div>
  }

  return <button onClick={connect}>Connect Wallet</button>
}

function WalletBalance() {
  const result = useAtomValue(balanceAtom)

  return Result.builder(result)
    .onInitial(() => <span>--</span>)
    .onSuccess((balance) =>
      balance ? <span>{formatEther(balance)} ETH</span> : <span>Not connected</span>
    )
    .render()
}

Mutations with useAtomSet

import { Atom, useAtomSet } from "@effect-atom/atom-react"
import { Effect, Exit } from "effect"
import { SignerService } from "voltaire-effect"

// Create mutation atom using runtime.fn
const sendTxAtom = runtime.fn(
  Effect.fnUntraced(function* (params: { to: `0x${string}`; value: bigint }) {
    const signer = yield* SignerService
    return yield* signer.sendTransaction(params)
  })
)

function SendButton() {
  const sendTx = useAtomSet(sendTxAtom, { mode: "promiseExit" })

  const handleSend = async () => {
    const exit = await sendTx({
      to: "0x742d35Cc6634C0532925a3b844Bc9e7595f1E5B3",
      value: 100000000000000000n
    })

    if (Exit.isSuccess(exit)) {
      console.log("TX Hash:", exit.value)
    } else {
      console.error("Failed:", exit.cause)
    }
  }

  return <button onClick={handleSend}>Send 0.1 ETH</button>
}

Atom Families for Dynamic Data

Use Atom.family to create atoms dynamically by key:
import { Atom, useAtomValue } from "@effect-atom/atom-react"
import { Effect } from "effect"
import { call } from "voltaire-effect"

// ERC20 ABI fragment
const erc20BalanceOf = {
  name: "balanceOf",
  type: "function",
  inputs: [{ name: "account", type: "address" }],
  outputs: [{ name: "balance", type: "uint256" }]
} as const

// Create atoms dynamically by token address
const tokenBalanceAtom = Atom.family(
  (params: { token: `0x${string}`; account: `0x${string}` }) =>
    runtime.atom(
      call({
        to: params.token,
        abi: [erc20BalanceOf],
        functionName: "balanceOf",
        args: [params.account]
      })
    )
)

function TokenBalance({
  token,
  account
}: {
  token: `0x${string}`
  account: `0x${string}`
}) {
  // Each token/account pair gets its own cached atom
  const result = useAtomValue(tokenBalanceAtom({ token, account }))

  return Result.builder(result)
    .onInitial(() => <span>Loading...</span>)
    .onSuccess((balance) => <span>{balance.toString()}</span>)
    .onFailure(() => <span>Error</span>)
    .render()
}

Hooks Reference

HookPurpose
useAtomValue(atom)Read atom value (returns Result for async atoms)
useAtomSet(atom)Get setter function for mutations
useAtom(atom)Get [value, setter] tuple
useAtomRefresh(atom)Get function to refresh/refetch
useAtomSuspense(atom)Suspense-enabled reading (throws promise)

Best Practices

  1. Create runtime once at module level, not in components
  2. Compose layers before creating runtime - don’t chain multiple Effect.provide
  3. Use Atom.keepAlive for data that should persist across unmounts
  4. Use Atom.family for dynamic/parameterized atoms
  5. Use Atom.refreshOnWindowFocus for data that should refresh when user returns

Next.js Integration

// lib/atoms.ts
import { Atom } from "@effect-atom/atom-react"
import { Layer } from "effect"
import { Provider, HttpTransport } from "voltaire-effect"

const ProviderLayer = Provider.pipe(
  Layer.provide(HttpTransport(process.env.NEXT_PUBLIC_RPC_URL!))
)

export const runtime = Atom.runtime(ProviderLayer)
// app/providers.tsx
"use client"
import { RegistryProvider } from "@effect-atom/atom-react"

export function Providers({ children }: { children: React.ReactNode }) {
  return <RegistryProvider>{children}</RegistryProvider>
}
// app/layout.tsx
import { Providers } from "./providers"

export default function RootLayout({
  children
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

With Full Wallet Layer

For signing transactions, compose all required layers:
// lib/atoms.ts
import { Atom } from "@effect-atom/atom-react"
import { Layer } from "effect"
import { Provider, HttpTransport, Signer, LocalAccount } from "voltaire-effect"
import { Secp256k1Live, KeccakLive } from "voltaire-effect/crypto"

const CryptoLayer = Layer.mergeAll(Secp256k1Live, KeccakLive)
const TransportLayer = HttpTransport(process.env.NEXT_PUBLIC_RPC_URL!)
const ProviderLayer = Provider.pipe(Layer.provide(TransportLayer))

// Full wallet layer with signing capability
export const WalletLayer = Layer.mergeAll(
  Signer.Live,
  CryptoLayer,
  ProviderLayer
).pipe(Layer.provideMerge(LocalAccount(privateKey)))

export const walletRuntime = Atom.runtime(WalletLayer)

See Also