A side-by-side comparison of building the same Todo app with two different approaches.
Terminal 1 - Hotwire app (port 3000):
cd todo-hotwire
bin/rails serverTerminal 2 - React app (port 3001):
cd todo-react
bin/devThen open:
- http://localhost:3000 (Hotwire)
- http://localhost:3001 (React)
| Aspect | Hotwire | React |
|---|---|---|
| Server response | HTML fragments | JSON data |
| State location | Server (database) | Client (useState) + Server |
| JS lines written | ~0 (just markup) | ~80 lines |
| New dependencies | None (Rails default) | react, react-dom, esbuild |
| Build step | None | Required (esbuild) |
Hotwire (todo-hotwire/app/controllers/todos_controller.rb):
def create
@todo = Todo.new(todo_params)
if @todo.save
respond_to do |format|
format.turbo_stream # Returns HTML fragment
format.html { redirect_to todos_path }
end
end
endReact (todo-react/app/controllers/api/todos_controller.rb):
def create
@todo = Todo.new(todo_params)
if @todo.save
render json: @todo, status: :created # Returns JSON
end
endHotwire (todo-hotwire/app/views/todos/_todo.html.erb):
<%= turbo_frame_tag dom_id(todo) do %>
<div class="todo-item">
<%= button_to toggle_todo_path(todo), method: :patch %>
<span><%= todo.title %></span>
</div>
<% end %>React (todo-react/app/javascript/components/TodoItem.jsx):
export default function TodoItem({ todo, onToggle, onDelete }) {
return (
<div className="todo-item">
<button onClick={() => onToggle(todo.id)}>
{todo.completed ? '✓' : '○'}
</button>
<span>{todo.title}</span>
</div>
);
}- Add a todo in each app
- Show the response:
- Hotwire: HTML fragment like
<turbo-stream action="prepend">...</turbo-stream> - React: JSON like
{"id":1,"title":"Buy milk","completed":false}
- Hotwire: HTML fragment like
- Open DevTools > Network > JS
- Compare:
- Hotwire: ~90KB total (Turbo + Stimulus)
- React: ~1MB (React + ReactDOM + your code)
- Install React DevTools extension
- Show component tree and state updates
- Highlight: "This is extra tooling Hotwire doesn't need"
- Start both apps side by side
- Create a todo - watch Network tab
- Toggle complete - show the request/response difference
- Delete - show
turbo_stream.removevssetTodos(filter) - Disable JavaScript (DevTools > Settings > Debugger > Disable JS):
- Hotwire: Still works! Forms submit normally
- React: Blank page, completely broken
Hotwire Pros:
- No build step needed
- Smaller bundle size
- Works without JavaScript (progressive enhancement)
- Less code to write and maintain
- No client-side state sync issues
React Pros:
- Rich ecosystem of components
- Better for highly interactive UIs
- Familiar to many frontend developers
- Great DevTools for debugging
- More control over rendering
When to use which:
- Hotwire: CRUD apps, content sites, admin panels, "traditional" web apps
- React: Real-time collaboration, complex dashboards, SPAs, mobile apps (React Native)
hotwire-vs-react/
├── todo-hotwire/ # Rails 8 + Hotwire (Turbo + Stimulus)
│ ├── app/
│ │ ├── controllers/
│ │ │ └── todos_controller.rb
│ │ └── views/todos/
│ │ ├── index.html.erb
│ │ ├── _todo.html.erb
│ │ └── create.turbo_stream.erb
│ └── ...
│
└── todo-react/ # Rails 8 API + React
├── app/
│ ├── controllers/api/
│ │ └── todos_controller.rb
│ └── javascript/components/
│ ├── App.jsx
│ ├── TodoForm.jsx
│ ├── TodoList.jsx
│ └── TodoItem.jsx
└── ...
Key files
What to Show: Controller Hotwire: todo-hotwire/app/controllers/todos_controller.rb React: todo-react/app/controllers/api/todos_controller.rb ──────────────────────────────────────── What to Show: View/Component Hotwire: todo-hotwire/app/views/todos/_todo.html.erb React: todo-react/app/javascript/components/TodoItem.jsx ──────────────────────────────────────── What to Show: Turbo Stream Hotwire: todo-hotwire/app/views/todos/create.turbo_stream.erb React: (no equivalent) ──────────────────────────────────────── What to Show: State Management Hotwire: Server-side only React: todo-react/app/javascript/components/App.jsx (useState) Demo Highlights
- Network Tab: Show HTML fragments (Hotwire) vs JSON (React)
- Bundle Size: ~90KB vs ~1MB
- Disable JavaScript: Hotwire still works, React breaks
- React DevTools: Show component tree (extra tooling needed)
Overview
Create two separate Rails 8 todo applications to demonstrate the architectural differences between Hotwire
(Turbo + Stimulus) and React approaches.
Project Structure
hotwire-vs-react/
├── todo-hotwire/ # Rails 8 + Hotwire (default stack)
└── todo-react/ # Rails 8 API + React frontend
---
App 1: todo-hotwire (Rails + Hotwire)
Features
- Create, toggle complete, delete todos
- Real-time updates without page refresh (Turbo Frames/Streams)
- Minimal Stimulus for client-side interactions
Files to Create
1. Model: Todo with title:string, completed:boolean
2. Controller: TodosController with standard CRUD + toggle action
3. Views:
- index.html.erb - main list with Turbo Frame
- _todo.html.erb - partial for single todo (Turbo Stream target)
- _form.html.erb - form partial
4. Stimulus: Optional controller for optimistic UI
Key Code Patterns to Highlight
- turbo_frame_tag for partial page updates
- turbo_stream responses for create/update/delete
- Server-rendered HTML over the wire
- No JSON serialization needed
---
App 2: todo-react (Rails API + React)
Features
- Same functionality: create, toggle, delete
- React frontend with hooks (useState, useEffect)
- Fetch API for communication
Files to Create
1. Model: Same Todo model
2. Controller: Api::TodosController returning JSON
3. React Components:
- App.jsx - main container with state
- TodoList.jsx - renders list
- TodoItem.jsx - single todo with actions
- TodoForm.jsx - form for new todos
4. Entry point: Mount React in a Rails view
Key Code Patterns to Highlight
- JSON API responses
- React state management with hooks
- Client-side rendering
- Explicit fetch calls and loading states
---
Presentation Guide
1. Code Comparison Points
┌───────────────────┬──────────────────────┬───────────────────────────────┐
│ Aspect │ Hotwire │ React │
├───────────────────┼──────────────────────┼───────────────────────────────┤
│ Server response │ HTML fragments │ JSON data │
├───────────────────┼──────────────────────┼───────────────────────────────┤
│ State management │ Server (database) │ Client (React state) + Server │
├───────────────────┼──────────────────────┼───────────────────────────────┤
│ JavaScript needed │ ~10 lines (Stimulus) │ ~100+ lines (Components) │
├───────────────────┼──────────────────────┼───────────────────────────────┤
│ New dependencies │ None (built-in) │ react, react-dom, build tools │
├───────────────────┼──────────────────────┼───────────────────────────────┤
│ Controller code │ Standard Rails │ API-style with JSON │
└───────────────────┴──────────────────────┴───────────────────────────────┘
2. Files to Show Side-by-Side
Controllers
- todos_controller.rb (Hotwire) vs api/todos_controller.rb (React)
- Show: HTML response vs JSON response
Views/Components
- _todo.html.erb vs TodoItem.jsx
- Show: Server template vs client component
Creating a Todo
- Hotwire: Form submits, Turbo Stream appends HTML
- React: Form calls API, updates state, re-renders
3. Developer Tools Demo
Network Tab
- Hotwire: Show HTML fragments in responses
- React: Show JSON payloads
Elements Tab
- Hotwire: Watch elements update in place
- React: Show React DevTools component tree
Performance
- Hotwire: Smaller JS bundle, faster initial load
- React: Larger bundle, but faster subsequent interactions
4. Live Demo Script
1. Start both apps (port 3000 and 3001)
2. Add a todo in each - show network requests
3. Toggle complete - show partial update vs full re-render
4. Delete - show Turbo Stream remove vs state update
5. Open DevTools - compare bundle sizes
6. Disable JavaScript - Hotwire still works (forms), React breaks
---
Implementation Steps
1. Create todo-hotwire Rails app with scaffold
2. Add Turbo Frame/Stream enhancements
3. Create todo-react Rails API app
4. Add React with esbuild
5. Build React components
6. Add minimal styling to both
7. Test both apps work correctly
---
Verification
1. Run todo-hotwire on port 3000:
- Can create, complete, and delete todos
- No full page reloads
2. Run todo-react on port 3001:
- Same functionality
- React DevTools shows component updates
3. Compare in browser DevTools:
- Network responses (HTML vs JSON)
- Bundle sizes
- With JS disabled
- Turbo Drive (Page Navigation)
What it does: Intercepts link clicks, fetches via AJAX, replaces
Demo steps:
-
Open DevTools → Network tab
-
Click "About (Turbo Drive Demo)" link
-
Show the audience: - Request type is fetch, not document - No browser loading spinner - URL changes, back button works - Content swapped without full reload
-
Turbo Frames (Partial Updates)
What it does: Only updates the matching element
Demo steps:
- Add a few todos
- Click "Edit" on one todo
- Show the audience: - Only that single row changes to a form - Rest of page is untouched - In Network tab: response contains full page, but only matching frame is used
Code to show:
<%= turbo_frame_tag dom_id(todo) do %> ... <% end %>
<%= turbo_frame_tag dom_id(@todo) do %> <% end %>
- Turbo Streams (DOM Manipulation)
What it does: Server sends HTML with actions like append, prepend, replace, remove
Demo steps:
- Open Network tab, filter by "Fetch/XHR"
- Add a todo → Show response:
- Toggle complete → Show response:
- Delete a todo → Show response:
Code to show (create.turbo_stream.erb): <%= turbo_stream.prepend "todos", @todo %> <%= turbo_stream.replace "new_todo" do %> <% end %>
Network Tab Responses to Show ┌──────────┬───────────────────────────────────────────────────┐ │ Action │ Turbo Stream Response │ ├──────────┼───────────────────────────────────────────────────┤ │ Create │ │ ├──────────┼───────────────────────────────────────────────────┤ │ Toggle │ │ ├──────────┼───────────────────────────────────────────────────┤ │ Delete │ │ ├──────────┼───────────────────────────────────────────────────┤ │ Navigate │ Full HTML document (Turbo Drive swaps body) │ ├──────────┼───────────────────────────────────────────────────┤ │ Edit │ Full HTML but only used │ └──────────┴───────────────────────────────────────────────────┘