Skip to content

Commit 5e5e515

Browse files
committed
add alternative usereducer elm post
1 parent 083b291 commit 5e5e515

File tree

1 file changed

+235
-0
lines changed

1 file changed

+235
-0
lines changed
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
---
2+
title: "The Discipline of Constraints: What Elm Taught Me About React's useReducer"
3+
date: 2025-09-18
4+
description: "How enforced discipline in one language can teach you better patterns in another - lessons from crossing the Elm/React boundary"
5+
tags: ["elm", "react", "usereducer", "architecture", "functional-programming"]
6+
draft: true
7+
---
8+
9+
## The Accidental Teacher
10+
11+
I've been thinking about discipline lately. Not the "wake up at 5 AM and eat nothing but kale" kind, but the more interesting variety: the kind that comes from working within constraints that make bad choices impossible.
12+
13+
Last month, I found myself in an unusual position. After spending several months deep in Elm land - where the compiler is your strict but helpful mentor - I returned to a React codebase that was enthusiastically using `useReducer` everywhere. The whiplash was immediate and instructive.
14+
15+
You see, both approaches solve the same fundamental problem: managing complex state changes in a predictable way. But experiencing Elm's enforced discipline first made me realize just how much rope React gives you to hang yourself with - and, more importantly, how to avoid doing exactly that.
16+
17+
This isn't another "Elm vs React" post. This is about what happens when you take the lessons from a language that won't let you make mistakes and apply them to one that absolutely will.
18+
19+
## Same Shape, Different Guardrails
20+
21+
The patterns look almost identical at first glance:
22+
23+
```elm path=null start=null
24+
-- Elm: The compiler ensures you handle every case
25+
type Msg
26+
= LoadUser String
27+
| UserLoaded (Result Http.Error User)
28+
| UpdateName String
29+
30+
update : Msg -> Model -> ( Model, Cmd Msg )
31+
update msg model =
32+
case msg of
33+
LoadUser id ->
34+
( { model | loading = True }
35+
, Http.get { url = "/users/" ++ id, expect = Http.expectJson UserLoaded userDecoder }
36+
)
37+
38+
UserLoaded (Ok user) ->
39+
( { model | user = Just user, loading = False }, Cmd.none )
40+
41+
UserLoaded (Err error) ->
42+
( { model | error = Just error, loading = False }, Cmd.none )
43+
44+
UpdateName name ->
45+
( { model | user = Maybe.map (\u -> { u | name = name }) model.user }, Cmd.none )
46+
```
47+
48+
```tsx path=null start=null
49+
// React: You can handle every case... or not
50+
type Action =
51+
| { type: "LOAD_USER"; id: string }
52+
| { type: "USER_LOADED"; user: User }
53+
| { type: "USER_FAILED"; error: string }
54+
| { type: "UPDATE_NAME"; name: string };
55+
56+
const reducer = (state: State, action: Action): State => {
57+
switch (action.type) {
58+
case "LOAD_USER":
59+
return { ...state, loading: true };
60+
61+
case "USER_LOADED":
62+
return { ...state, user: action.user, loading: false };
63+
64+
case "USER_FAILED":
65+
return { ...state, error: action.error, loading: false };
66+
67+
case "UPDATE_NAME":
68+
return {
69+
...state,
70+
user: state.user ? { ...state.user, name: action.name } : state.user,
71+
};
72+
73+
default:
74+
return state; // This line is doing a lot of heavy lifting
75+
}
76+
};
77+
```
78+
79+
Both follow the same mental model: messages/actions flow in, new state flows out, effects happen on the side. But there's a crucial difference hiding in that innocent-looking `default` case.
80+
81+
## The Tyranny of Choice
82+
83+
In Elm, if you add a new message variant and forget to handle it, your code simply won't compile. The type checker becomes your pair programming partner, gently (but firmly) reminding you about edge cases you've forgotten.
84+
85+
In React with TypeScript, you get some of this safety - the discriminated union helps, and if you're disciplined about your typing, you'll catch missing cases. But the `default` case is an escape hatch that's always available. And escape hatches have a way of being used.
86+
87+
Here's what I learned from going Elm → React: **the `default` case is where discipline goes to die.**
88+
89+
I lost count of how many React reducers I found that looked like this:
90+
91+
```tsx path=null start=null
92+
const reducer = (state: State, action: any) => {
93+
switch (action.type) {
94+
case "LOAD_USER":
95+
return { ...state, loading: true };
96+
97+
case "USER_LOADED":
98+
return { ...state, user: action.payload, loading: false };
99+
100+
// ... a few more cases ...
101+
102+
default:
103+
return state; // "Eh, we'll handle the rest later"
104+
}
105+
};
106+
```
107+
108+
That `any` type crept in because someone needed to add a quick action and didn't want to deal with TypeScript complaints. The `default` case silently swallows unhandled actions. You've gone from a system that forces you to think through every state transition to one that lets you sweep complexity under the rug.
109+
110+
Elm wouldn't have let this slide for a second.
111+
112+
## Effects: The Other Half of the Equation
113+
114+
But the real education came when dealing with side effects. In Elm, effects are data:
115+
116+
```elm path=null start=null
117+
-- Elm: Effects are just data describing what should happen
118+
update : Msg -> Model -> ( Model, Cmd Msg )
119+
update msg model =
120+
case msg of
121+
LoadUser id ->
122+
( { model | loading = True }
123+
, Http.get {
124+
url = "/users/" ++ id
125+
, expect = Http.expectJson UserLoaded userDecoder
126+
}
127+
)
128+
```
129+
130+
The `Cmd` value is a description of an effect to perform. The Elm runtime handles executing it, manages cancellation, and ensures results come back through your update function. You never escape the loop.
131+
132+
In React, effects are... well, effects:
133+
134+
```tsx path=null start=null
135+
const UserProfile = ({ userId }: { userId: string }) => {
136+
const [state, dispatch] = useReducer(reducer, initialState);
137+
138+
useEffect(() => {
139+
dispatch({ type: "LOAD_USER", id: userId });
140+
141+
const controller = new AbortController();
142+
143+
fetch(`/users/${userId}`, { signal: controller.signal })
144+
.then((response) => response.json())
145+
.then((user) => dispatch({ type: "USER_LOADED", user }))
146+
.catch((error) => {
147+
if (error.name !== "AbortError") {
148+
dispatch({ type: "USER_FAILED", error: error.message });
149+
}
150+
});
151+
152+
return () => controller.abort();
153+
}, [userId]);
154+
155+
// ... rest of component
156+
};
157+
```
158+
159+
You're back in the imperative world of manual cleanup, race condition management, and "did I remember to handle the error case?" The reducer gives you a nice pure core, but effects still happen in the messy, error-prone world of `useEffect`.
160+
161+
## The Lesson: Constraints Enable Creativity
162+
163+
What Elm taught me wasn't that React's approach is wrong - it's that discipline is a muscle that needs exercise. When the language forces you to be disciplined, you develop better habits. When it doesn't, you need to bring that discipline yourself.
164+
165+
After my Elm detour, I found myself writing React code differently:
166+
167+
1. **Never use `any` in action types.** If TypeScript is complaining about your action shape, fix the types, don't silence the compiler.
168+
169+
2. **Never add a default case that just returns state.** If you're not handling an action, be explicit about it - throw an error or add a comment explaining why it's ignored.
170+
171+
3. **Encapsulate effects in custom hooks.** Create hooks that dispatch actions rather than performing effects directly in components.
172+
173+
```tsx path=null start=null
174+
// Instead of mixing effects directly in components
175+
const useFetchUser = (userId: string, dispatch: Dispatch<Action>) => {
176+
useEffect(() => {
177+
if (!userId) return;
178+
179+
dispatch({ type: "LOAD_USER", id: userId });
180+
181+
const controller = new AbortController();
182+
183+
fetchUser(userId, controller.signal)
184+
.then((user) => dispatch({ type: "USER_LOADED", user }))
185+
.catch((error) => {
186+
if (error.name !== "AbortError") {
187+
dispatch({ type: "USER_FAILED", error: error.message });
188+
}
189+
});
190+
191+
return () => controller.abort();
192+
}, [userId, dispatch]);
193+
};
194+
```
195+
196+
4. **Design invalid states out of existence.** Instead of separate booleans for `loading`, `error`, and `data`, use discriminated unions:
197+
198+
```tsx path=null start=null
199+
type UserState =
200+
| { status: "idle" }
201+
| { status: "loading" }
202+
| { status: "success"; user: User }
203+
| { status: "error"; error: string };
204+
```
205+
206+
This prevents impossible states like `loading: true, error: "Something went wrong"` that can cause confusing UI states.
207+
208+
## The Deeper Pattern
209+
210+
The real insight isn't about Elm vs React - it's about constraint-driven design. Working in a language that makes certain mistakes impossible teaches you to recognize and avoid those same mistakes when they become possible again.
211+
212+
Elm's constraints taught me better patterns for `useReducer`. The compiler's insistence on totality made me more careful about edge cases. The enforced purity of the update function made me think harder about where effects belong.
213+
214+
## Bringing Elm's Discipline to React
215+
216+
If you've never tried Elm but work with `useReducer` regularly, here are some constraints I learned to impose on myself:
217+
218+
- **Exhaustive action handling**: Comment explicitly when you're intentionally ignoring an action.
219+
- **Total state transitions**: Think through what should happen to every piece of state for every action.
220+
- **Effect isolation**: Keep effects in custom hooks that communicate through dispatch.
221+
- **Invalid state elimination**: Use TypeScript's discriminated unions to make impossible states unrepresentable.
222+
223+
You don't need Elm's compiler to enforce these patterns, but experiencing enforced discipline helps you recognize when you're being undisciplined.
224+
225+
## The Craft Connection
226+
227+
This connects back to something I've been thinking about regarding [coding as craft](/posts/coding-as-craft-going-back-to-the-old-gym/). Master craftsmen often impose constraints on themselves - not because they have to, but because constraints force innovation and build skill.
228+
229+
The discipline I learned from Elm's compiler made me a better React developer. The constraints didn't limit my creativity; they channeled it in more productive directions.
230+
231+
When you're building state management with `useReducer`, you're not just solving the immediate problem - you're practicing a way of thinking about state, time, and change. The habits you build in one context carry over to others.
232+
233+
The real question isn't "Which approach is better?" It's "What can I learn from this constraint that will make me better when the constraint is removed?"
234+
235+
Sometimes the best teacher is a language that simply won't let you make certain mistakes. Even if you never ship Elm to production, the lessons in discipline are worth the price of admission.

0 commit comments

Comments
 (0)