From ERB to JavaScript - Server Computes, Hydration Joins, Templates Filter
This post documents the third iteration of offline-first scoring for the showcase application—and the architecture that finally feels right. Previous posts covered Web Components (4,025 lines), Turbo MVC (2,604 lines, incomplete), and automatic ERB-to-JavaScript conversion (1,933 lines). This post describes how these pieces fit together into a coherent offline-first architecture.
The core insight: Server computes, hydration joins, templates filter.
The Problem Recap
During live dance competitions, hotel WiFi is notoriously unreliable. Judges score performances on tablets, and network outages during scoring are mission-critical failures. The solution: a Single Page Application that works offline while keeping Rails as the source of truth.
Previous iterations taught important lessons:
- Web Components (4,025 lines): Proved offline capability works, but Shadow DOM and component lifecycles added unfamiliar complexity
- Turbo MVC (2,604 lines, incomplete): Simpler architecture, but required maintaining parallel JavaScript templates alongside ERB
- ERB-to-JS conversion (1,933 lines): Eliminated template duplication by automatically converting ERB to JavaScript
The final architecture combines automatic template conversion with a clean data pipeline.
Architecture: Server Computes, Hydration Joins, Templates Filter
This principle guides the entire implementation:
1. Server Computes
Rails extracts all data a judge needs for an event, normalized like a relational database. Instead of sending nested objects for each heat:
{
"heat": {
"number": 5,
"dance": { "name": "Waltz", "category": "Smooth" },
"entry": {
"lead": { "name": "John Smith", "back": 101, "studio": { "name": "Fred Astaire" } },
"follow": { "name": "Jane Doe", "back": 102, "studio": { "name": "Fred Astaire" } }
}
}
}
The server sends lookup tables:
{
"heats": [{ "id": 5, "number": 5, "dance_id": 12, "entry_id": 42 }],
"dances": { "12": { "name": "Waltz", "category": "Smooth" } },
"entries": { "42": { "lead_id": 101, "follow_id": 102 } },
"people": { "101": { "name": "John Smith", "back": 101, "studio_id": 7 },
"102": { "name": "Jane Doe", "back": 102, "studio_id": 7 } },
"studios": { "7": { "name": "Fred Astaire" } }
}
Why? A person appearing in 50 heats is transmitted once, not 50 times. For a mid-sized event, this compresses ~13MB of nested data to ~176KB of normalized data.
The server also computes derived values. For example, dance_string (a formatted display string combining dance name, level, and category) is computed server-side because it requires complex business logic. These computed values travel with the normalized data.
2. Hydration Joins
On the client, buildLookupTables() and hydrateHeat() in heat_hydrator.js reconstruct nested objects by resolving IDs:
export function buildLookupTables(rawData) {
return {
people: new Map(Object.entries(rawData.people || {})),
studios: new Map(Object.entries(rawData.studios || {})),
dances: new Map(Object.entries(rawData.dances || {})),
entries: new Map(Object.entries(rawData.entries || {})),
// ... etc
};
}
export function hydrateHeat(heat, lookups) {
// Convert IDs to full objects
const dance = lookups.dances.get(String(heat.dance_id));
const entry = lookups.entries.get(String(heat.entry_id));
// Hydrate nested relationships
entry.lead = lookups.people.get(String(entry.lead_id));
entry.lead.studio = lookups.studios.get(String(entry.lead.studio_id));
// ... etc
return { ...heat, dance, entry };
}
The result is a data structure that can be traversed like ActiveRecord collections—heat.entry.lead.studio.name works just like in Ruby.
3. Templates Filter
JavaScript templates (automatically converted from ERB) receive hydrated data and render HTML. They don't compute—they just filter and format:
// Generated from ERB: <%= subject.entry.lead.studio.name %>
html += (subject.entry.lead.studio.name) || '';
// Generated from ERB: <% @subjects.each do |subject| %>
data.subjects.forEach(subject => {
// render subject row
});
The templates are identical to their ERB counterparts because they're automatically converted, not hand-written.
ERB-to-JavaScript Conversion
Ruby 3.4's Prism parser enables automatic conversion. ERB templates contain Ruby expressions—loops, conditionals, method calls—that Prism parses into an Abstract Syntax Tree. The converter walks this AST and generates equivalent JavaScript.
Rails endpoint (/templates/scoring.js) dynamically converts ERB templates:
class TemplatesController < ApplicationController
def scoring
templates = %w[heat heatlist _heat_header _info_box _navigation_footer
_cards_heat _rank_heat _solo_heat _table_heat]
converter = ErbPrismConverter.new
js_modules = templates.map do |name|
erb_path = Rails.root.join("app/views/scores/#{name}.html.erb")
erb_content = File.read(erb_path)
function_name = name.delete_prefix('_').camelize(:lower)
converter.convert(erb_content, function_name: function_name)
end
render js: js_modules.join("\n\n"), content_type: 'text/javascript'
end
end
Translation examples:
# Instance variables → data object
@subjects → data.subjects
@event.open_scoring → data.event.open_scoring
# Blocks → arrow functions
<% @subjects.each do |subject| %> → data.subjects.forEach(subject => {
# ... // ...
<% end %> });
# Rails helpers → JavaScript equivalents
dom_id(heat) → domId(heat)
link_to(...) → `<a href="..." data-turbo-frame="...">`
The converter handles nested conditionals, string interpolation, safe navigation (&. → ?.), and Rails path helpers.
Data Flow: Online and Offline
Initial Load
- Stimulus controller connects (
heat_app_controller.js) - Load templates once:
fetch('/templates/scoring.js')→ cached ES modules - Load all data once:
fetch('/scores/:judge/heats/data')→ 176KB normalized JSON - Build lookup tables: Convert arrays to Maps for O(1) access
- Render current heat: Hydrate data, call template function, update DOM
Heat Navigation (Online)
- User clicks next/prev heat
- Version check:
fetch('/scores/:judge/version/:heat')returns lightweight metadata:{ "max_updated_at": "2025-11-24T15:30:00Z", "heat_count": 142 } - Compare versions: If unchanged, use cached data (no fetch)
- If changed: Fetch fresh data, update cache
- Render: Hydrate heat from in-memory data, call template, update DOM
Heat Navigation (Offline)
- Version check fails (network error)
- Fall back to cached data: Already in memory
- Continue navigation: All data was loaded initially
- Queue score updates: Store in IndexedDB for later upload
Score Entry
Online:
- User enters score
- POST to
/scores/:judge/post - Update in-memory data
- Update IndexedDB cache
Offline:
- POST fails (network unavailable)
- Queue in IndexedDB:
DirtyScoresQueuestores score with timestamp - Update in-memory data (optimistic)
- Show offline indicator
Reconnection
- Detect connectivity restored (version check succeeds on navigation, or browser
onlineevent) - Batch upload:
POST /scores/:judge/batchwith all queued scores - Clear dirty queue on success
- Fetch fresh data if stale (compare
max_updated_atfrom version check)
Service Worker: Simple Cache
The service worker uses network-first strategy:
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then(response => {
// Cache successful GET responses
if (response.ok) {
const cache = await caches.open('showcase-v1');
cache.put(event.request, response.clone());
}
return response;
})
.catch(() => caches.match(event.request)) // Fallback to cache
);
});
This ensures:
- Fresh data when online: Always try network first
- Graceful degradation when offline: Serve cached responses
- No complex cache invalidation: Cache just provides fallback
Debugging: render_erb_and_js.rb
This script compares ERB and JavaScript rendering side-by-side:
$ RAILS_APP_DB=2025-barcelona-november ruby scripts/render_erb_and_js.rb 83 136
Rendering ERB version...
✓ ERB rendered: 12,847 bytes, 8 <tr> tags
Saved to: /tmp/erb_rendered.html
Rendering JavaScript version...
✓ Templates fetched: 38,912 bytes
✓ Heat 136 hydrated with 1 subjects
Saved template data to: /tmp/js_template_data.json
✓ JS rendered: 12,847 bytes, 8 <tr> tags
Saved to: /tmp/js_rendered.html
Files saved to /tmp/ for analysis:
/tmp/erb_rendered.html - ERB template output
/tmp/js_rendered.html - JavaScript template output
/tmp/scoring_templates.js - Converted templates
/tmp/js_template_data.json - Complete template data
When outputs differ, you can:
diff /tmp/erb_rendered.html /tmp/js_rendered.htmlto see differences- Inspect
/tmp/js_template_data.jsonto verify data correctness - Examine
/tmp/scoring_templates.jsto debug conversion issues
This compresses hours of debugging into minutes.
Code Comparison
Final Implementation
Infrastructure:
- ERB to JS converter: 841 lines (ErbPrismConverter)
- Stimulus controller: 270 lines
- Heat hydrator: 380 lines
- Templates controller: 101 lines
- Dirty scores queue: 276 lines
- Service worker: 65 lines
Auto-generated (not counted):
- JavaScript templates: ~1,100 lines (from existing ERB)
vs. Previous Approaches
| Approach | Hand-written Code | Notes |
|---|---|---|
| Web Components | 4,025 lines | Shadow DOM, component lifecycle |
| Turbo MVC | 2,604 lines | Template duplication, incomplete |
| ERB-to-JS | 1,933 lines | Single source of truth |
Result: 52% less code than Web Components, with guaranteed template parity.
What This Enables
For Judges:
- Score heats during WiFi outages
- Navigate between heats without network
- Automatic upload when connectivity returns
- No special action required—it just works
For Developers:
- Single template codebase (ERB)
- Changes to ERB automatically reflected in JavaScript
- Powerful debugging tools
- Familiar Rails patterns
For Operations:
- Same data bandwidth as Web Components (176KB)
- Version checks minimize unnecessary data transfer
- Service worker provides emergency fallback
Remaining Work
The architecture is complete. What remains:
- UI polish: Offline indicator, pending scores badge
- Error recovery: Handle partial batch upload failures
- Production validation: Test at actual live events
See the implementation plan for detailed steps.
Lessons Learned
Fast Iteration Enables Multiple Rewrites
Three complete implementations in three weeks. Without Claude Code compressing exploration time, I wouldn't have attempted the second rewrite—let alone the third. Each iteration revealed insights that led to a better solution.
Invert the Problem
Both Web Components and Turbo MVC required maintaining parallel templates. The breakthrough came from asking: "What if ERB were the source of truth and JavaScript were generated?" This eliminated duplication entirely.
Architecture Principles Guide Implementation
"Server computes, hydration joins, templates filter" provides clarity at every decision point. Should this logic be in the controller or template? Check the principle. Should this computation happen server-side or client-side? Check the principle.
Build Debugging Tools Early
render_erb_and_js.rb emerged organically but became essential. When template behavior differs, having intermediate artifacts (normalized data, hydrated data, generated JavaScript) makes debugging straightforward.
Try It Yourself
All components are open source:
- Showcase application: github.com/rubys/showcase
- ERB converter:
lib/erb_prism_converter.rb - Heat hydrator:
app/javascript/lib/heat_hydrator.js - Stimulus controller:
app/javascript/controllers/heat_app_controller.js - Debugging script:
scripts/render_erb_and_js.rb
Conclusion
Server computes, hydration joins, templates filter.
This architecture delivers offline-first scoring with:
- 52% less code than Web Components
- Single template codebase (no dual maintenance)
- Guaranteed parity (automatic conversion)
- Powerful debugging (intermediate artifacts)
The journey through three implementations wasn't wasted effort—it was essential exploration that compressed the solution space. Each "failed" approach revealed what actually needed solving.
Building resilient event applications isn't about choosing simpler architectures—it's about finding the right abstraction that eliminates essential complexity.
For the architectural context of showcase's multi-tenant deployment, see Shared-Nothing Multi-Tenancy. For the development methodology, see Disciplined use of Claude.
For complete implementation details including converter tests, hydration logic, and offline queue management, see the showcase repository.