Frontend Frameworks
Choosing the right frontend framework matters. Each has real trade-offs, and "more powerful" doesn't mean "better for your project."
LLM-Friendliness Scores
We evaluated each framework for how easily an LLM can understand, navigate, and modify the code:
| Dimension | UIx | Re-frame | Fulcro |
|---|---|---|---|
| Conceptual Understanding | 5 | 4 | 4 |
| Code Navigation | 5 | 4 | 4 |
| State Inspection | 5 | 5 | 3 |
| Modification Task | 5 | 4 | 3 |
| Debugging | 5 | 4 | 4 |
| Framework Idioms | 5 | 4 | 4 |
| Weighted Total | 5.0/5.0 | 4.15/5.0 | 3.60/5.0 |
| LLM-Friendliness | 9.5/10 | 8.3/10 | 6.5/10 |
UIx - Simple, Explicit, Fast to Build
Choose UIx when:
- You’re building an MVP or prototype
- Your data is mostly flat (lists, forms, simple state)
- You want the fastest LLM-assisted development
- Your team knows React hooks
- Entities don’t repeat across multiple UI locations
Real-world examples where UIx wins:
- Todo apps, note-taking apps
- Landing pages with interactive elements
- Simple dashboards with independent widgets
- CRUD interfaces for single entities
- Developer tools and utilities
Why it works: UIx is closest to raw React. State lives in atoms, components subscribe explicitly with use-atom. No magic, no indirection. An LLM can trace data flow in seconds.
;; UIx: State is just an atom, subscription is explicit
(let [state (uix/use-atom app-state)
todos (:todos state)]
($ :ul (for [todo todos] ...)))
Re-frame - Structured, Scalable, Team-Friendly
Choose Re-frame when:
- You need explicit patterns your team can follow
- You want excellent debugging tools (re-frame-10x)
- Your app has complex event chains (async flows, side effects)
- You’re building a medium-to-large SPA
- You value the large community and documentation
Real-world examples where Re-frame wins:
- Multi-page SPAs with complex routing
- Apps with significant async operations (API calls, WebSockets)
- Projects where multiple developers need consistent patterns
- Apps requiring time-travel debugging
- When you want enforced unidirectional data flow
Why it works: Re-frame’s event/subscription pattern creates guardrails. Every state change goes through an event handler. Every read goes through a subscription. This makes debugging deterministic and patterns consistent across a team.
;; Re-frame: Explicit event dispatch, subscription-based reads
(rf/dispatch [:add-todo "Buy milk"])
(let [todos @(rf/subscribe [:todos])] ...)
Fulcro - Normalized Data, Automatic Consistency
Choose Fulcro when:
- The same entity appears in multiple UI locations
- You have deeply nested or graph-like data
- You need optimistic updates with rollback capability
- You want server-driven UI with Pathom
- Data consistency across components is critical
Real-world examples where Fulcro wins:
- Social apps: A user’s name/avatar shown in header, comments, profile, and friend lists - update once, reflects everywhere
- E-commerce: Product appears in catalog, cart, recommendations, and order history
- Admin panels: Entity relationships (users -> orders -> products) with cross-referencing
- Collaborative tools: Real-time updates where multiple users edit shared data
- CRMs: Contact shown in multiple contexts (deals, emails, meetings, notes)
Why it works: Fulcro’s normalized database stores each entity exactly once. Components hold references (idents), not copies. When data changes, every component showing that entity updates automatically.
;; Fulcro: Normalized state - Alice exists in ONE place
{:person/id {123 {:person/id 123 :person/name "Alice" :person/avatar "..."}
456 {:person/id 456 :person/name "Bob"}}
:root/current-user [:person/id 123] ; Reference, not copy
:root/friends [[:person/id 456]]} ; References, not copies
;; Update Alice's name once -> every component showing Alice updates
The Data Duplication Problem
Consider an app where a user appears in 3 places:
- Header: “Welcome, Alice”
- Sidebar: Alice’s avatar
- Profile page: Full details
In UIx/Re-frame (flat state):
;; Alice's data might be duplicated
{:current-user {:name "Alice" :avatar "..."}
:sidebar {:user {:name "Alice" :avatar "..."}}
:profile {:user {:name "Alice" :avatar "..." :email "..."}}}
;; When Alice updates her name, you must update ALL locations
;; Or refetch everything
;; Or build your own normalization layer
In Fulcro (normalized state):
;; Alice exists exactly once
{:person/id {123 {:person/id 123 :person/name "Alice" :person/avatar "..." :person/email "..."}}
:root/current-user [:person/id 123] ; Just a pointer
:sidebar/user [:person/id 123] ; Same pointer
:profile/user [:person/id 123]} ; Same pointer
;; Update once -> UI updates everywhere automatically
When Fulcro is Overkill
Fulcro’s normalization is pure overhead when:
- Entities don’t repeat across the UI (most simple apps)
- Data is flat (todo list, form wizard)
- You’re prototyping and speed matters more than architecture
- Your team doesn’t already know Fulcro
For a todo app, Fulcro adds ~3x complexity for zero benefit. The normalized database, idents, queries, and mutations are all cognitive tax with no payoff.
Decision Matrix
| Your Situation | Framework | Why |
|---|---|---|
| Building an MVP | UIx | Fastest to iterate, lowest complexity |
| Todo app, notes app, simple CRUD | UIx | Flat data, no entity duplication |
| Team needs consistent patterns | Re-frame | Enforced architecture, great tooling |
| Complex async flows | Re-frame | Event chains, effect handlers |
| Same entity in multiple UI locations | Fulcro | Automatic consistency |
| Admin panel with entity relationships | Fulcro | Graph data, cross-references |
| E-commerce (product in cart + listing + recs) | Fulcro | No duplicate product data |
| Real-time collaborative editing | Fulcro | Optimistic updates, rollback |
| API-only backend | nil | No frontend needed |
Files to Touch Per Feature
Adding a typical feature (e.g., “Clear Completed” button):
| Framework | Files | Notes |
|---|---|---|
| UIx | 4 | state.cljc, server, ws/client, ui component |
| Re-frame | 5 | state.cljc, server, ws/client, events.cljs, ui component |
| Fulcro | 2-3 | mutations.cljs, ui component (+ optional ws) |
The LLM Trade-off
For LLM-assisted development, complexity is a tax on every interaction.
With Fulcro, the LLM must:
- Understand normalized vs denormalized state
- Know which table to update
- Remember to update both table AND references
- Understand query composition with
comp/get-query - Handle ident resolution
This context overhead eats into the LLM’s effective capability. For simple apps, you’re paying this tax for nothing.
Bottom line: Fulcro’s power is real, but it’s power you only need for apps with significant data relationship complexity. For most apps, UIx gives you 90% of the capability with 20% of the cognitive load.