← notes

effect global state pattern

a pattern for managing reactive app state using Effect's SubscriptionRef and Atom runtime — accessible from both React components and non-React contexts (interceptors, WebSockets, utilities) without duplication or manual sync.

this pattern is based on Harry Solovay's tweet: https://x.com/harrysolovay/status/2054956734284734716


core concept

traditional React state (useState, Zustand, Jotai) is React-only. if you need the same state in an Axios interceptor or a WebSocket handler, you end up maintaining a separate store and syncing them manually.

this pattern solves that by placing state inside an Effect service layer, then exposing it through an Atom Runtime that works in any context.


architecture

┌────────────────────────────────────────────────────┐
│                    Effect Layer                    │
│                                                    │
│   ┌─────────────────────────────────────────────┐  │
│   │            SubscriptionRef<State>           │  │
│   │         (single source of truth)            │  │
│   └──────────────────┬──────────────────────────┘  │
│                      │                             │
│   ┌──────────────────▼──────────────────────────┐  │
│   │               AppState Service              │  │
│   │         Context.Service<AppState>           │  │
│   └──────────────────┬──────────────────────────┘  │
└─────────────────────-│─────────────────────────────┘
                       │ wrapped by
                       ▼
          ┌───────────────────────┐
          │      Atom Runtime     │
          │   runtime.Provider    │
          └────────┬──────────────┘
                   │
         ┌─────────┴──────────┐
         │                    │
         ▼                    ▼
  ┌─────────────┐    ┌─────────────────────┐
  │   Inside    │    │    Outside React    │
  │    React    │    │                     │
  │             │    │  Axios interceptors │
  │  useAtom()  │    │  WebSocket handlers │
  │             │    │  Utility functions  │
  └──────┬──────┘    └──────────┬──────────┘
         │                      │
         ▼                      ▼
  auto re-render         runtime.get()
  on state change        .subscribe()

key pieces

piece role
SubscriptionRef holds state, broadcasts changes to all subscribers
AppState service registers the ref as an injectable Effect service
Atom Runtime bridges the Effect layer to the outside world
rootStateAtom the public handle, read/write from anywhere
countAtom derived atom, plucks a slice, avoids unnecessary re-renders

implementation

1. state definition — state.ts

import { Context, Layer } from "effect"
import { SubscriptionRef } from "effect"
import { Atom } from "@effect-rx/rx"

// shape of your app state
type State = { count: number }
const makeInitial = (): State => ({ count: 0 })

// effect service wrapping a reactive SubscriptionRef
export class AppState extends Context.Service<AppState>()("AppState", {
  make: SubscriptionRef.make(makeInitial()),
}) {
  static readonly layer = Layer.effect(this, this.make)
}

// atom runtime — the bridge between Effect and the outside world
export const runtime = Atom.runtime(AppState.layer)

// rootStateAtom — single source of truth, usable anywhere
export const rootStateAtom = runtime.subscriptionRef(AppState.asEffect())

// derived atom — only triggers re-render when count specifically changes
export const countAtom = rootStateAtom.pipe(
  Atom.mapResult(({ count }) => count)
)

2. app entry point — main.tsx

wrap your app with runtime.Provider so all child components can access atoms.

import { runtime } from "./state"

function App() {
  return (
    <runtime.Provider>
      <Counter />
      <OtherComponents />
    </runtime.Provider>
  )
}

3. inside React — Counter.tsx

use the useAtom hook. the component re-renders automatically when the atom value changes.

import { countAtom, rootStateAtom } from "./state"

function Counter() {
  // subscribes to countAtom — re-renders only when count changes
  const count = useAtom(countAtom)

  const increment = () =>
    rootStateAtom.update(state => ({ ...state, count: state.count + 1 }))

  const decrement = () =>
    rootStateAtom.update(state => ({ ...state, count: state.count - 1 }))

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  )
}

4. Outside React

two modes depending on whether you need a snapshot or a live subscription.

snapshot - Axios interceptor

import axios from "axios"
import { runtime, rootStateAtom } from "./state"

// runtime.get() — synchronous one-time read
axios.interceptors.request.use((config) => {
  const state = runtime.get(rootStateAtom)
  config.headers["X-Count"] = state.count
  return config
})

reactive - subscribe to changes

import { rootStateAtom } from "./state"

// .subscribe() — fires a callback on every state change
const unsubscribe = rootStateAtom.subscribe((state) => {
  console.log("state changed:", state.count)
  analytics.track("count_updated", { value: state.count })
})

// clean up when no longer needed
unsubscribe()

consumption patterns

                    rootStateAtom
                         │
          ┌──────────────┼──────────────┐
          │              │              │
          ▼              ▼              ▼
     useAtom()      runtime.get()  .subscribe()
          │              │              │
     React hook     Sync snapshot   Reactive cb
          │              │              │
   auto re-render   read once on    fires on
   when state       demand (e.g.    every change
   changes          interceptor)    (e.g. logger)
method context behaviour
useAtom(atom) React components reactive, triggers re-renders
runtime.get(atom) anywhere synchronous snapshot, one-time read
atom.subscribe(cb) anywhere reactive, fires callback on every change

derived atoms

derived atoms let you subscribe to a slice of state instead of the whole object, preventing unnecessary re-renders.

// full state atom, re-renders on any state change
const rootStateAtom = runtime.subscriptionRef(AppState.asEffect())

// derived, only re-renders when count changes
const countAtom = rootStateAtom.pipe(
  Atom.mapResult(({ count }) => count)
)

// another derived, only re-renders when user changes
const userAtom = rootStateAtom.pipe(
  Atom.mapResult(({ user }) => user)
)
  rootStateAtom { count, user, theme, ... }
         │
         ├──── countAtom  ──► CounterComponent
         ├──── userAtom   ──► UserProfileComponent
         └──── themeAtom  ──► ThemeToggleComponent

each component only re-renders when its specific slice changes.


summary

  state lives in Effect (SubscriptionRef)
       ↓
  wrapped in AppState service
       ↓
  exposed via Atom Runtime
       ↓
  consumed as atoms — same API everywhere

  React?        →  useAtom(atom)
  Interceptor?  →  runtime.get(atom)
  Side effect?  →  atom.subscribe(cb)

the key insight: state is defined once, in one place, and consumed uniformly regardless of where in the app you are. no duplicate stores, no manual sync, no context passing.


dependencies

effect                    — core Effect library
@effect-atom/atom         — Atom runtime
@effect-atom/atom-react   — useAtom hook for React