4 min · 632 words reactjavascriptprogrammingarchitectureelm

Hook Soup Is Not Architecture

Discover why embracing Elm's architecture might transform your chaotic React components into a streamlined, bug-resistant system.

React starts beautifully. A component, some props, one useState.

Then the app becomes real.


You fetch a user. That needs an effect. The effect needs an ID. Another state depends on the user. A callback needs both. Something syncs to local storage. Something else gets memoized because a child re-rendered.

Congratulations. Your component is now a distributed system held together by dependency arrays.


Here's what it looks like:

const [query, setQuery] = useState("")
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [submitted, setSubmitted] = useState(false)

useEffect(() => {
  if (!submitted) return
  setLoading(true)
  setError(null)
  search(query)
    .then(r => { setResults(r); setLoading(false) })
    .catch(e => { setError(e); setLoading(false) })
    .finally(() => setSubmitted(false))
}, [submitted, query])

Can loading be true while error is set? Is results stale? Does the effect fire again when query changes mid-flight?

Nobody knows. Not you. Not the type system. Definitely not the next dev.


Elm has three things:

  • Model: all your state
  • Msg: something that happened
  • update: how a Msg changes the Model

That's it. No lifecycle to reconstruct. No graph to execute in your head.

type Search
    = NotAsked
    | Loading
    | Loaded (List Result)
    | Failed Error

type Msg
    = QueryChanged String
    | Submitted
    | Completed (Result Error (List Result))

update msg model =
    case msg of
        QueryChanged q ->
            ( { model | query = q }, Cmd.none )

        Submitted ->
            ( { model | search = Loading }, runSearch model.query )

        Completed (Ok r) ->
            ( { model | search = Loaded r }, Cmd.none )

        Completed (Err e) ->
            ( { model | search = Failed e }, Cmd.none )

Five booleans became one type. Impossible states are now impossible.

Boring. Good.


useEffect says: whenever these values happen to change, do something.

Elm says: this happened, so do this.

One is a sequence of events. The other is a Rube Goldberg machine made of closures.


Debugging stops being archaeology. A bug report is a list of messages:

Submitted → Completed → ItemSelected → CheckoutFailed

Initial model plus messages equals what happened. Testing is a function call. No DOM. No mocked hooks. No act() warnings.


The price: Elm makes you decide what your screen actually is before you write it.

React optimizes for the first two hours of a feature.

Elm optimizes for month twelve, developer six, redesign three, and the requirement that "should be pretty simple."


You don't need Elm. Steal the idea.

I did. tea is my tiny TypeScript take on the Elm Architecture. Zero dependencies. No React. Just TypeScript and esbuild.

Same search, in tea:

const Msg = {
  QueryChanged: (q: string) => ["QueryChanged", q] as const,
  Submitted: () => ["Submitted"] as const,
  Completed: (r: Result[]) => ["Completed", r] as const,
  Failed: (e: Error) => ["Failed", e] as const,
}
type Msg = MsgOf<typeof Msg>

const runSearch = (q: string) =>
  Cmd.attempt(() => api.search(q), Msg.Completed, Msg.Failed)

const update = (msg: Msg, model: Model) => match(msg, {
  QueryChanged: (q) => ({ ...model, query: q }),
  Submitted: () => withCmd({ ...model, search: { tag: "loading" } }, runSearch(model.query)),
  Completed: (r) => ({ ...model, search: { tag: "loaded", results: r } }),
  Failed: (e) => ({ ...model, search: { tag: "failed", error: e } }),
})

match is exhaustive. Forget a message and the compiler complains, not your users.

Views never touch dispatch. Handlers return messages. The runtime does the rest.

Stuck in React? useReducer gets you halfway. Dispatch events, not setters. Replace boolean piles with a union. Trigger effects from events, not from state drifting past a dependency array.


Freedom is what frameworks sell.

Constraints are what you need.

Elm doesn't remove complexity. It just stops letting it hide.