|
| 1 | +--- |
| 2 | +title: "The Elm Architecture vs React's useReducer, or 'Same Loop, Different Guarantees'" |
| 3 | +date: 2025-09-18 |
| 4 | +description: "A practical comparison of The Elm Architecture and React's useReducer: how they map conceptually, where they differ in guarantees, and what to watch out for when scaling apps." |
| 5 | +tags: ["elm", "react", "usereducer", "architecture", "state-management"] |
| 6 | +draft: true |
| 7 | +--- |
| 8 | + |
| 9 | +I love how often smart engineers arrive at the same shape of solution from different directions. The Elm Architecture (TEA) and React's `useReducer` are a great example: two ecosystems, one mental model. |
| 10 | + |
| 11 | +But this isn't a "which one is better" piece. |
| 12 | + |
| 13 | +Instead, this is a side‑by‑side look at the same loop expressed in two worlds—what maps neatly, what absolutely doesn't, and how the guarantees you get change the way you structure code at scale. |
| 14 | + |
| 15 | +## The Loop We All Build |
| 16 | + |
| 17 | +At heart, both TEA and `useReducer` revolve around a simple loop: |
| 18 | + |
| 19 | +1. A message/action happens |
| 20 | +2. You compute a new state |
| 21 | +3. You render that state |
| 22 | +4. You perform effects that cause more messages/actions |
| 23 | + |
| 24 | +In Elm, this loop is explicit and enforced. In React, it's conventional and flexible. |
| 25 | + |
| 26 | +### Elm |
| 27 | + |
| 28 | +```elm |
| 29 | +module Counter exposing (Model, Msg(..), init, update, view, subscriptions) |
| 30 | + |
| 31 | +import Browser |
| 32 | +import Html exposing (Html, button, div, text) |
| 33 | +import Html.Events exposing (onClick) |
| 34 | + |
| 35 | +type alias Model = |
| 36 | + { count : Int } |
| 37 | + |
| 38 | +type Msg |
| 39 | + = Increment |
| 40 | + | Decrement |
| 41 | + |
| 42 | +init : () -> ( Model, Cmd Msg ) |
| 43 | +init _ = |
| 44 | + ( { count = 0 }, Cmd.none ) |
| 45 | + |
| 46 | +update : Msg -> Model -> ( Model, Cmd Msg ) |
| 47 | +update msg model = |
| 48 | + case msg of |
| 49 | + Increment -> |
| 50 | + ( { model | count = model.count + 1 }, Cmd.none ) |
| 51 | + |
| 52 | + Decrement -> |
| 53 | + ( { model | count = model.count - 1 }, Cmd.none ) |
| 54 | + |
| 55 | +view : Model -> Html Msg |
| 56 | +view model = |
| 57 | + div [] |
| 58 | + [ button [ onClick Decrement ] [ text "-" ] |
| 59 | + , div [] [ text (String.fromInt model.count) ] |
| 60 | + , button [ onClick Increment ] [ text "+" ] |
| 61 | + ] |
| 62 | + |
| 63 | +subscriptions : Model -> Sub Msg |
| 64 | +subscriptions _ = Sub.none |
| 65 | +``` |
| 66 | + |
| 67 | +### React with useReducer |
| 68 | + |
| 69 | +```jsx |
| 70 | +import { useReducer } from "react"; |
| 71 | + |
| 72 | +const initialState = { count: 0 }; |
| 73 | + |
| 74 | +const Action = { |
| 75 | + Increment: { type: "Increment" }, |
| 76 | + Decrement: { type: "Decrement" }, |
| 77 | +}; |
| 78 | + |
| 79 | +const reducer = (state, action) => { |
| 80 | + switch (action.type) { |
| 81 | + case "Increment": |
| 82 | + return { ...state, count: state.count + 1 }; |
| 83 | + case "Decrement": |
| 84 | + return { ...state, count: state.count - 1 }; |
| 85 | + default: |
| 86 | + return state; |
| 87 | + } |
| 88 | +}; |
| 89 | + |
| 90 | +export const Counter = () => { |
| 91 | + const [state, dispatch] = useReducer(reducer, initialState); |
| 92 | + |
| 93 | + return ( |
| 94 | + <div> |
| 95 | + <button onClick={() => dispatch(Action.Decrement)}>-</button> |
| 96 | + <div>{state.count}</div> |
| 97 | + <button onClick={() => dispatch(Action.Increment)}>+</button> |
| 98 | + </div> |
| 99 | + ); |
| 100 | +}; |
| 101 | +``` |
| 102 | + |
| 103 | +Same idea. Different enforcement. |
| 104 | + |
| 105 | +## Effects: `Cmd` vs `useEffect` |
| 106 | + |
| 107 | +Effects are where the similarities carry you 80% of the way and then the last 20% decides your production story. |
| 108 | + |
| 109 | +- **Elm `Cmd Msg`**: declarative, described in pure code, executed by the runtime, results come back as `Msg` via the same update loop. |
| 110 | +- **React `useEffect`**: imperative side‑effects run after render; you orchestrate async work yourself and call `dispatch` when ready. |
| 111 | + |
| 112 | +### Elm: fetch on click |
| 113 | + |
| 114 | +```elm |
| 115 | +type Msg |
| 116 | + = Fetch |
| 117 | + | Fetched (Result Http.Error String) |
| 118 | + |
| 119 | +update : Msg -> Model -> ( Model, Cmd Msg ) |
| 120 | +update msg model = |
| 121 | + case msg of |
| 122 | + Fetch -> |
| 123 | + ( model |
| 124 | + , Http.get |
| 125 | + { url = "/api/greeting" |
| 126 | + , expect = Http.expectString Fetched |
| 127 | + } |
| 128 | + ) |
| 129 | + |
| 130 | + Fetched (Ok txt) -> |
| 131 | + ( { model | greeting = txt }, Cmd.none ) |
| 132 | + |
| 133 | + Fetched (Err _) -> |
| 134 | + ( { model | greeting = "Oops" }, Cmd.none ) |
| 135 | +``` |
| 136 | + |
| 137 | +No mutation leaks, no forgotten cleanup. The runtime guarantees ordering and cancellation semantics. |
| 138 | + |
| 139 | +### React: fetch on click |
| 140 | + |
| 141 | +```jsx |
| 142 | +const reducer = (state, action) => { |
| 143 | + switch (action.type) { |
| 144 | + case "Fetch": |
| 145 | + return { ...state, loading: true }; |
| 146 | + case "Fetched": |
| 147 | + return { ...state, loading: false, greeting: action.payload }; |
| 148 | + case "FetchFailed": |
| 149 | + return { ...state, loading: false, error: true }; |
| 150 | + default: |
| 151 | + return state; |
| 152 | + } |
| 153 | +}; |
| 154 | + |
| 155 | +const fetchGreeting = async (dispatch, signal) => { |
| 156 | + try { |
| 157 | + const res = await fetch("/api/greeting", { signal }); |
| 158 | + const txt = await res.text(); |
| 159 | + dispatch({ type: "Fetched", payload: txt }); |
| 160 | + } catch (e) { |
| 161 | + if (e.name !== "AbortError") dispatch({ type: "FetchFailed" }); |
| 162 | + } |
| 163 | +}; |
| 164 | + |
| 165 | +export const Greeting = () => { |
| 166 | + const [state, dispatch] = useReducer(reducer, { |
| 167 | + loading: false, |
| 168 | + greeting: "", |
| 169 | + error: false, |
| 170 | + }); |
| 171 | + |
| 172 | + useEffect(() => { |
| 173 | + if (!state.loading) return; |
| 174 | + const controller = new AbortController(); |
| 175 | + fetchGreeting(dispatch, controller.signal); |
| 176 | + return () => controller.abort(); |
| 177 | + }, [state.loading]); |
| 178 | + |
| 179 | + return ( |
| 180 | + <button onClick={() => dispatch({ type: "Fetch" })}> |
| 181 | + {state.loading ? "Loading..." : state.greeting || "Load"} |
| 182 | + </button> |
| 183 | + ); |
| 184 | +}; |
| 185 | +``` |
| 186 | + |
| 187 | +It works well—but the discipline is on you. Cleanup, idempotency, and avoiding render‑effect feedback loops are not enforced by the framework. |
| 188 | + |
| 189 | +## Where They Map 1:1 |
| 190 | + |
| 191 | +- **Model/State**: Elm `Model` ↔ React reducer state. |
| 192 | +- **Msg/Action**: Elm `Msg` union ↔ React action objects. |
| 193 | +- **Update/Reducer**: Pure function, input is message/action and old state; output is new state. |
| 194 | +- **View**: Elm `Html Msg` ↔ React JSX with `dispatch` passed through. |
| 195 | + |
| 196 | +If you stick to those, `useReducer` feels very Elm‑ish. |
| 197 | + |
| 198 | +## Where Guarantees Diverge |
| 199 | + |
| 200 | +- **Type system**: Elm's `Msg` is an exhaustive union with compiler‑enforced handling. React needs TypeScript and discipline; you can still forget cases or widen to `any`. |
| 201 | +- **Side‑effects**: Elm turns effects into data (`Cmd`) processed by the runtime. React executes effects directly in `useEffect`, making ordering, cancellation and concurrency your responsibility. |
| 202 | +- **Global invariants**: Elm prevents invalid states by construction ("make impossible states impossible"). React can emulate via types and state modeling, but nothing stops you from sprinkling additional `useState` that bypasses your reducer. |
| 203 | +- **Refactoring safety**: Elm's compiler becomes your pair programmer. In React, the compiler helps if you invest in strict TypeScript and ESLint rules. |
| 204 | +- **Debugging/time travel**: Elm debugger is built‑in. React has excellent devtools but time travel depends on external tooling and reducer purity. |
| 205 | + |
| 206 | +## Scaling the Codebase |
| 207 | + |
| 208 | +Both approaches scale, but the pressure points differ. |
| 209 | + |
| 210 | +- **Elm** |
| 211 | + |
| 212 | + - Break large modules into feature modules with their own `Model/Msg/update/view` and compose. |
| 213 | + - Use the pattern championed by Richard Feldman (see the `elm-spa-example`) to keep message routing explicit. |
| 214 | + - JSON decoding/encoding is explicit; friction up‑front, fewer surprises later. |
| 215 | + |
| 216 | +- **React** |
| 217 | + - Co‑locate reducers per feature; lift them into context providers when shared. |
| 218 | + - Guard against reducer bypass: prefer reducer‑only state for domain data, `useState` only for purely local UI details. |
| 219 | + - Encapsulate effects in custom hooks that dispatch actions, mirroring Elm `Cmd` modules. |
| 220 | + |
| 221 | +## Common Foot‑guns (and How to Avoid Them) |
| 222 | + |
| 223 | +- **Effect leaks** |
| 224 | + |
| 225 | + - Elm: runtime handles cancellation; your job is to model messages. |
| 226 | + - React: always return a cleanup from `useEffect`, and use `AbortController` for fetch. |
| 227 | + |
| 228 | +- **Invalid states** |
| 229 | + |
| 230 | + - Elm: encode impossible states in types (e.g., `type RemoteData a = NotAsked | Loading | Success a | Failure`). |
| 231 | + - React: mirror that with discriminated unions in TypeScript and a single reducer. |
| 232 | + |
| 233 | +- **Reducer escape hatches** |
| 234 | + - Elm: not possible; all state changes flow through `update`. |
| 235 | + - React: avoid mixing `useState` that mutates the same domain; route through `dispatch`. |
| 236 | + |
| 237 | +## Choosing for a Project |
| 238 | + |
| 239 | +- **Choose Elm when** you want strong guarantees, explicitness by default, and a runtime that enforces the architecture. The learning curve is front‑loaded; the payoff is in refactoring safety and fewer production surprises. |
| 240 | + |
| 241 | +- **Choose React + useReducer when** you need to integrate with a large React ecosystem, want flexibility, and are willing to enforce constraints via conventions, TypeScript, and linting. |
| 242 | + |
| 243 | +Both can yield beautiful, maintainable systems if you design with the loop in mind and treat effects as first‑class concerns. |
| 244 | + |
| 245 | +## A Handy Mapping Table |
| 246 | + |
| 247 | +- **Elm `Model`**: React reducer state |
| 248 | +- **Elm `Msg`**: React action (prefer discriminated unions in TS) |
| 249 | +- **Elm `update`**: React reducer function |
| 250 | +- **Elm `Cmd`**: React `useEffect` + async function that dispatches |
| 251 | +- **Elm `subscriptions`**: React event sources (intervals, websockets) wrapped in `useEffect` |
| 252 | +- **Elm ports**: Boundary to JS world ↔ React imperative escape hatches (but keep them at the edges) |
| 253 | + |
| 254 | +## Closing Thought |
| 255 | + |
| 256 | +Same loop. Different guarantees. If you adopt Elm's discipline inside React—single reducer per domain, discriminated unions, effects as data‑producers that funnel back through `dispatch`—you get most of TEA's clarity. If you adopt React's pragmatism inside Elm—clear module boundaries and small, composable features—you get most of the ergonomics people love about React. |
| 257 | + |
| 258 | +Either way, design for change. Future‑you will thank present‑you. |
| 259 | + |
| 260 | +--- |
| 261 | + |
| 262 | +_Further reading: Richard Feldman's `elm-spa-example` for project organization, and the React docs on `useReducer` and `useEffect` for effect modeling._ |
0 commit comments