Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/tim-smart/effect-atom/llms.txt

Use this file to discover all available pages before exploring further.

Overview

Optimistic updates provide instant feedback to users by updating the UI immediately, before the server confirms the change. Effect Atom provides Atom.optimistic and Atom.optimisticFn to implement this pattern with automatic rollback on errors.
Optimistic updates are essential for responsive UIs in applications with network latency. They make your app feel instant while maintaining data consistency.

Basic concept

The optimistic update flow:
  1. User triggers an action (e.g., clicks “Like”)
  2. UI updates immediately with the optimistic value
  3. Request is sent to the server
  4. On success: UI keeps the optimistic value
  5. On failure: UI rolls back to the previous value

Using Atom.optimistic

Atom.optimistic wraps an atom to enable optimistic updates:
import { Atom } from "@effect-atom/atom-react"

// Regular atom
const countAtom = Atom.make(0)

// Wrap with optimistic behavior
const optimisticCountAtom = countAtom.pipe(Atom.optimistic)

// Type: Writable<number, Atom<Result.Result<number, unknown>>>

Type signature

function optimistic<A>(
  self: Atom<A>
): Writable<A, Atom<Result.Result<A, unknown>>>
The optimistic atom:
  • Reads from the source atom
  • Accepts a “transition atom” as its write value
  • Displays optimistic values while transitions are pending
  • Reverts to the source value when transitions complete

Using Atom.optimisticFn

Atom.optimisticFn creates a function that performs optimistic updates:
import { Atom, Result } from "@effect-atom/atom-react"
import { Effect } from "effect"

const countAtom = Atom.make(0)
const optimisticCountAtom = countAtom.pipe(Atom.optimistic)

const incrementFn = optimisticCountAtom.pipe(
  Atom.optimisticFn({
    // Define how to compute the optimistic value
    reducer: (current, _update: void) => current + 1,
    
    // Define the actual mutation
    fn: Atom.fn(Effect.fnUntraced(function*() {
      // Make API call
      yield* Effect.sleep("1 second")
      yield* Effect.log("Incremented")
    }))
  })
)

// Usage
function Counter() {
  const count = useAtomValue(optimisticCountAtom)
  const increment = useAtomSet(incrementFn)
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => increment()}>Increment</button>
    </div>
  )
}

Type signature

function optimisticFn<A, W, XA, XE, OW = void>(
  self: Writable<A, Atom<Result.Result<W, unknown>>>,
  options: {
    readonly reducer: (current: A, update: OW) => W
    readonly fn:
      | AtomResultFn<OW, XA, XE>
      | ((set: (result: W) => void) => AtomResultFn<OW, XA, XE>)
  }
): AtomResultFn<OW, XA, XE>
Parameters:
  • reducer: Function that computes the optimistic value from the current value and update
  • fn: The actual mutation function, or a factory that receives a setter for intermediate updates

Complete example with API

import { AtomHttpApi, Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react"
import { FetchHttpClient, HttpApi, HttpApiEndpoint, HttpApiGroup } from "@effect/platform"
import { Atom } from "@effect-atom/atom"
import { Schema } from "effect"

// Define API
class Api extends HttpApi.make("api").add(
  HttpApiGroup.make("counter").add(
    HttpApiEndpoint.get("count", "/count")
      .addSuccess(Schema.Number)
  ).add(
    HttpApiEndpoint.post("increment", "/increment")
      .addSuccess(Schema.Number)
  )
) {}

class CountClient extends AtomHttpApi.Tag<CountClient>()("CountClient", {
  api: Api,
  httpClient: FetchHttpClient.layer,
  baseUrl: "http://localhost:3000"
}) {}

// Create atoms
const countAtom = CountClient.query("counter", "count", {
  reactivityKeys: ["count"]
})

const optimisticCountAtom = countAtom.pipe(Atom.optimistic)

const incrementFn = optimisticCountAtom.pipe(
  Atom.optimisticFn({
    reducer: (current, _update: void) => {
      const count = Result.getOrElse(current, () => 0)
      return Result.success(count + 1)
    },
    fn: CountClient.mutation("counter", "increment")
  })
)

// Component
function Counter() {
  const count = useAtomValue(optimisticCountAtom)
  const increment = useAtomSet(incrementFn)
  
  return Result.builder(count)
    .onSuccess((value, { waiting }) => (
      <div>
        <p>
          Count: {value}
          {waiting && <span> (saving...)</span>}
        </p>
        <button onClick={() => increment({ payload: void 0, reactivityKeys: ["count"] })}>
          Increment
        </button>
      </div>
    ))
    .render()
}

Intermediate updates

You can update the optimistic value during the mutation:
const uploadFn = optimisticFileAtom.pipe(
  Atom.optimisticFn({
    reducer: (current, file: File) => ({
      ...current,
      status: "uploading",
      progress: 0
    }),
    fn: (set) =>
      Atom.fn(Effect.fnUntraced(function*(file: File) {
        // Update progress during upload
        const upload = uploadFile(file, (progress) => {
          set({
            status: "uploading",
            progress
          })
        })
        
        yield* upload
      }))
  })
)

Error handling

When the mutation fails, the optimistic value is automatically rolled back:
const incrementFn = optimisticCountAtom.pipe(
  Atom.optimisticFn({
    reducer: (current, _update: void) => current + 1,
    fn: Atom.fn(Effect.fnUntraced(function*() {
      yield* Effect.sleep("1 second")
      // Simulate error
      yield* Effect.fail("Network error")
    }))
  })
)

function Counter() {
  const count = useAtomValue(optimisticCountAtom)
  const [error, setError] = useState<string>()
  const increment = useAtomSet(incrementFn, { mode: "promiseExit" })
  
  const handleIncrement = async () => {
    const exit = await increment()
    if (Exit.isFailure(exit)) {
      setError(Cause.pretty(exit.cause))
    }
  }
  
  return (
    <div>
      <p>Count: {count}</p>
      {error && <p style={{ color: "red" }}>{error}</p>}
      <button onClick={handleIncrement}>Increment</button>
    </div>
  )
}

Working with non-Result atoms

Optimistic updates work with regular atoms too:
const countAtom = Atom.make(0)
const optimisticCountAtom = countAtom.pipe(Atom.optimistic)

const incrementFn = optimisticCountAtom.pipe(
  Atom.optimisticFn({
    // Reducer works directly with the value
    reducer: (current, _update: void) => current + 1,
    fn: Atom.fn(Effect.fnUntraced(function*() {
      yield* Effect.sleep("1 second")
    }))
  })
)

Synchronous mutations

For synchronous mutations, the optimistic and final values converge immediately:
const incrementFn = optimisticCountAtom.pipe(
  Atom.optimisticFn({
    reducer: (current, update: number) => update,
    fn: Atom.fn(() => {
      // Synchronous update
      i = 2
      return Effect.void
    })
  })
)

Best practices

1
Use for user-initiated actions
2
Optimistic updates work best for explicit user actions (clicks, form submissions):
3
// Good: User clicks button
<button onClick={() => likePost()}>Like</button>

// Avoid: Automatic actions that users don't control
4
Provide visual feedback
5
Show when an optimistic update is pending:
6
Result.builder(post)
  .onSuccess((post, { waiting }) => (
    <div>
      <h1>{post.title}</h1>
      {waiting && <span>Saving...</span>}
    </div>
  ))
  .render()
7
Handle failures gracefully
8
Always handle mutation failures and inform the user:
9
const handleAction = async () => {
  const exit = await mutate()
  if (Exit.isFailure(exit)) {
    toast.error("Action failed. Please try again.")
  }
}
10
Keep reducers pure
11
The reducer should be a pure function:
12
// Good
reducer: (current, update) => current + update

// Bad: side effects
reducer: (current, update) => {
  console.log("updating") // Side effect!
  return current + update
}
13
Test both success and failure paths
14
Ensure your UI handles both optimistic success and rollback:
15
it("rolls back on failure", async () => {
  const r = Registry.make()
  
  expect(r.get(optimisticAtom)).toEqual(0)
  r.set(incrementFn, void 0)
  
  // Optimistic value
  expect(r.get(optimisticAtom)).toEqual(1)
  
  // Simulate failure
  latch.unsafeOpen()
  await Effect.runPromise(Effect.yieldNow())
  
  // Rolled back
  expect(r.get(optimisticAtom)).toEqual(0)
})

Advanced patterns

Multiple optimistic updates

Stack multiple optimistic updates:
const optimisticAtom = sourceAtom.pipe(Atom.optimistic, Atom.keepAlive)

const fn1 = optimisticAtom.pipe(Atom.optimisticFn({ /* ... */ }))
const fn2 = optimisticAtom.pipe(Atom.optimisticFn({ /* ... */ }))

// Both mutations can be in-flight simultaneously
setFn1(void 0)
setFn2(void 0)

Conditional optimistic updates

Only apply optimistic updates in certain conditions:
reducer: (current, update) => {
  if (current.status === "readonly") {
    return current // No optimistic update
  }
  return { ...current, ...update }
}

Pessimistic updates

For critical operations, skip optimistic updates:
// Use regular mutations without Atom.optimistic
const deleteFn = Atom.fn(Effect.fnUntraced(function*() {
  yield* deleteUser()
  // Only update UI after confirmation
}))

Comparison with other patterns

PatternUI UpdateRollbackUse Case
OptimisticImmediateAutomaticUser actions, low error rate
PessimisticAfter successN/ACritical operations, high error rate
StreamingProgressiveN/ALong-running operations

Resources

Build docs developers (and LLMs) love