React's Evolution Toward Elm

React's Evolution Toward Elm

A history of how React, originally a class-based component framework, gradually adopted The Elm Architecture patterns. Most React developers don't know they're writing Elm-flavoured code; the lineage is real and worth understanding.

The story is roughly: React started imperative-with-classes (2013); Redux brought reducers (2015); Hooks made functional components mainstream (2018); useReducer normalised the pattern (2018); modern React-with-server-components is moving even further toward declarative + immutable.

By 2026, idiomatic React is closer to Elm than to its own 2015 self.

Phase 1: Class components (2013–2018)

Original React. Stateful components held mutable state in this.state; updates via this.setState:

class Counter extends React.Component {
    constructor() {
        super();
        this.state = { count: 0 };
    }
    increment = () => {
        this.setState({ count: this.state.count + 1 });
    };
    render() {
        return (
            <button onClick={this.increment}>
                {this.state.count}
            </button>
        );
    }
}

Component-local state. Mutations via callbacks. View was a method on the class. Lifecycle methods (componentDidMount, componentWillUpdate) for side effects. Familiar to anyone who'd written GUIs in OO languages — and increasingly clunky as apps grew.

Phase 2: Redux (2015)

Dan Abramov released Redux at React Europe 2015, demoing time-travel debugging. Redux pulled state out of components into a single global store, mutated only via reducers.

function counterReducer(state = { count: 0 }, action) {
    switch (action.type) {
        case "INCREMENT": return { count: state.count + 1 };
        case "DECREMENT": return { count: state.count - 1 };
        default: return state;
    }
}

const store = createStore(counterReducer);
store.dispatch({ type: "INCREMENT" });
console.log(store.getState());  // { count: 1 }

This was The Elm Architecture in JS, openly. Dan's original talk credits Elm directly. Time-travel debugging was the demo; the architecture was the lasting contribution.

For five years, "React + Redux" was the default stack for non-trivial React apps. Boilerplate was high but the discipline was real.

Phase 3: Hooks (2018)

React 16.8 introduced hooks — functions like useState, useEffect, useReducer that let function components hold state and side effects without classes. The big change: function components became the default; classes deprecated in spirit.

function Counter() {
    const [count, setCount] = useState(0);
    return (
        <button onClick={() => setCount(count + 1)}>
            {count}
        </button>
    );
}

useState hides a reducer behind a tuple-returning function. useReducer exposes the reducer directly:

function counterReducer(state, action) {
    switch (action.type) {
        case "INCREMENT": return { count: state.count + 1 };
        default: return state;
    }
}

function Counter() {
    const [state, dispatch] = useReducer(counterReducer, { count: 0 });
    return (
        <button onClick={() => dispatch({ type: "INCREMENT" })}>
            {state.count}
        </button>
    );
}

Same shape as Elm/Redux. Smaller scope (per-component vs global). The pattern is now built into React itself.

Phase 4: useEffect (2018)

useEffect is React's Cmd equivalent — declarative side effects keyed to state changes:

function UserProfile({ userId }) {
    const [user, setUser] = useState(null);
    useEffect(() => {
        let cancelled = false;
        fetch(`/api/users/${userId}`)
            .then(r => r.json())
            .then(u => { if (!cancelled) setUser(u); });
        return () => { cancelled = true; };  // cleanup
    }, [userId]);  // re-run when userId changes
    return user ? <div>{user.name}</div> : <div>Loading…</div>;
}

Less pure than Elm's Cmd (the side effect runs inline rather than being returned as data), but the intent is the same: declare what side effects should happen given the current state, let React orchestrate them.

The cleanup function pattern handles "cancellation when state changes" — Elm subscriptions do something similar. The compositional bite is similar.

Phase 5: Server components and Suspense (2022–)

React Server Components, Suspense for data fetching, and the Next.js App Router push further toward declarative-fetching:

async function UserProfile({ userId }) {
    const user = await fetchUser(userId);
    return <div>{user.name}</div>;
}

The component is async; React suspends rendering until the data arrives. No useEffect, no useState. Closer to "view IS a function of inputs" than React has ever been.

What's still missing relative to Elm

React isn't Elm. Five gaps:

  1. No type safety guarantees. TypeScript helps, but React still allows mutation, runtime errors, missing dependencies. Elm's compiler is stricter.
  2. Hooks have rules ("only call hooks at the top level of components"). Elm's runtime has no such gotchas.
  3. State is per-component by default. Lifting state up is awkward. Elm's single Model is simpler architecturally (and harder for casual use).
  4. Side effects are inline. useEffect calls fetch directly. Elm's Cmd returns a description; the runtime fetches.
  5. Concurrency model is opaque. React's "concurrent mode" is hidden behind hooks; Elm's runtime is explicit.

Modern React is "Elm-flavoured" but not "Elm-strict." That's intentional — React optimises for adoption breadth. Elm optimises for guarantees.

Why this history matters

If you came to programming via React, The Elm Architecture feels like "Redux but in a different language." That's roughly right.

If you understand TEA from Elm, modern React makes more sense — the patterns React stumbled into over a decade are the patterns Elm shipped on day one.

If you're building reactive UIs in any framework that has reducers + a render function (Bubbletea, tui-realm, ratatui-with-Elm-discipline, Lazydap's TUI), you're on the same architectural path. The names differ; the shape is the same.

What to remember

See also