Diary of a Rewrite, Episode 2 of 4.

Translating legacy code line by line is almost always a blueprint for failure. When development teams embark on modernizing critical internal tools, the instinctual trap is to map old paradigms directly onto new frameworks, code block by code block, observer by observer.

In the first installment of this series, we explored why we abandoned a premature stack shift halfway through and chose instead to return to our roots in the R ecosystem. Today, we turn our focus to the mechanics of the port itself—specifically, why my initial attempt to translate a production R Shiny application into a server-side rendered, htmx-hydrated architecture failed completely, and why that failure had nothing to do with technical limitations.


Main Facts: The Anatomy of a Legacy Tool and a Modern Stack

To understand the scope of the transition, we must examine the subject of the rewrite: StaffPuzzle, an internal staffing and resource-management tool utilized daily by the team at ThinkR.

StaffPuzzle determines resource allocation down to the half-day interval, reads live Google Calendars across the organization, and has operated reliably in production for years. Its original incarnation was built entirely in R Shiny, comprising approximately 1,200 lines of reactive code.

The objective of the modernization project was to rewrite the application using htmxr—a framework leveraging server-side rendered HTML hydrated via htmx, powered by the plumber2 routing backend.

However, the core thesis of this development cycle proved disruptive: the primary hurdle of porting a stateful application to a stateless, hypermedia-driven model is not understanding the destination syntax; it is abandoning the psychological crutch of the reactive graph.


Chronology: A Misguided Approach to Migration

The project’s initial trajectory followed a methodical, albeit fundamentally flawed, roadmap.

The False Start: Mapping Observers to Endpoints

Initially, I opened app_server.R, cataloged every individual observeEvent, and systematically set out to transform them into routes—allocating one distinct endpoint per observer. It was exhaustive and disciplined, and it resulted in an application riddled with endpoints that served no structural purpose.

In a traditional web application, a route pairs an address (URL) with a function that builds the response. The browser requests an asset; the server fulfills it. State is not retained between requests. Shiny, conversely, maintains a continuous, persistent conversation with the browser for the duration of the visit, managing an intricate, in-memory reactive dependency graph.

By attempting to translate observers directly into API routes, I was committing a category error. I was trying to preserve a reactive state-machine inside a hypermedia-driven architecture where state belongs in the URL or the database.

The Turning Point: Discovering the 60-Line Illusion

The realization crystallized over a piece of boilerplate code dedicated to URL-and-input synchronization. In the original Shiny application, 60 lines of code were required to maintain bidirectional parity between browser URLs and input widgets. When filters changed, the URL updated; when a URL was shared, the inputs reset to match.

On the htmxr side, this entire category of code vanished. Not because it was refactored into a shorter function, but because it had nothing left to do. In a hypermedia-driven architecture, when the URL is the state, there is no mirror left to maintain.

# The htmxr equivalent for URL synchronization and state handling
hx_set(
  form,
  get      = ".",
  target   = "#trainings-view",
  trigger  = "change",
  push_url = "true"
)

This structural realization shifted our guiding development philosophy: Do not pack by photographing every room of your old house to rebuild it identically in the new one; evaluate what you actually use and leave the rest behind.


Supporting Data: Mapping Shiny Patterns to Stateless Equivalents

Porting an application requires a clear ledger of what survives, what transforms, and what is discarded. The operational differences between Shiny and a hypermedia-driven R backend can be categorized across several distinct patterns:

Shiny Pattern Migration Cost Modern Equivalent (htmxr)
selectInput, actionButton, sliderInput Low hx_select_input(), hx_button(), hx_slider_input()
dateRangeInput Low Two standard HTML <input type="date"> elements
URL ⇄ Inputs Synchronization Low The native push_url argument
Static ggplot rendering Low svglite combined with an htmx fragment swap
DT data tables Low hx_table() and hx_table_rows()
Long-running tasks & notifications Medium External background jobs paired with periodic polling
Interactive plotly visualizations High Dedicated JavaScript binding (plotly.js) or discarding interactivity
Editable data grids (rhandsontable) High Heavy JS client libraries or individual cell-save forms

The Geyser Paradigm: A Comparative Case Study

To observe this architectural shift in practice, consider Shiny’s canonical example: the Old Faithful geyser histogram.

In traditional Shiny, the user interface and server logic intertwine:

ui <- page_sidebar(
  title = "Hello Shiny!",
  sidebar = sidebar(
    sliderInput("bins", "Number of bins:", min = 1, max = 50, value = 30)
  ),
  plotOutput("distPlot")
)

server <- function(input, output) 
  output$distPlot <- renderPlot(
    x    <- faithful$waiting
    bins <- seq(min(x), max(x), length.out = input$bins + 1)
    hist(x, breaks = bins, col = "darkgray", border = "white")
  )

In contrast, the htmxr implementation separates interface declarations from execution routes while keeping core computational logic untouched:

#* @get /
#* @serializer html
function() 
  hx_page(
    hx_head(title = "Old Faithful Geyser Data"),
    hx_slider_input(
      id      = "bins",
      label   = "Number of bins:",
      value   = 30, min = 1, max = 50,
      get     = "plot",
      trigger = "input changed delay:300ms",
      target  = "#plot"
    ),
    tags$div(id = "plot") 

#* @get /plot
#* @serializer none
function(query) 
  xmlSVG(
    x    <- faithful$waiting
    bins <- seq(min(x), max(x), length.out = as.numeric(query$bins %, width = 7, height = 5)

The underlying mathematical computation—the four lines calculating the histogram—remains identical. What changes is the wiring. In Shiny, the reactive graph implicitly deduces relationships. In a stateless hypermedia model, wiring instructions (get, target, trigger) are explicit and declarations are transparent.


Official Responses and Engineering Realities

Reflecting on the engineering overhaul, the transition highlighted deep truths about how software maintainability evolves.

According to internal development metrics tracked across the StaffPuzzle repository branches:

  • Test Coverage: The ratio of tests-to-code shifted dramatically from 0.26 to 0.77, yielding 544 robust tests executed in milliseconds without requiring headless browsers or visual regression snapshots.
  • Separation of Concerns: By stripping away ambient reactive contexts, business logic transformed into pure, testable R functions capable of ingesting data and returning structured HTML or JSON.

However, the engineering team offers a pragmatic caveat regarding applicability. The reactive graph inherent to Shiny remains exceptionally powerful when building deeply interconnected, multi-variable dashboards where every widget constantly influences every other widget in real-time.


Implications: When Should You Port, and When Should You Stay?

The ultimate decision-making criterion for modernizing an R-based analytical application boils down to a single architectural question: Does your application’s state fit neatly inside a URL?

  • When the Answer is Yes: If your application operates primarily around parameters like date ranges, user filters, and sorting rules—where views represent distinct slices of underlying data—porting to a hypermedia-driven architecture like htmxr pays massive dividends in maintainability, performance, and testability. Furthermore, your core analytics logic instantly transforms into an accessible API consumable by external frontends (such as React, Vue, or mobile apps) without additional engineering overhead.
  • When the Answer is No: If your users rely on complex, multi-step exploratory sessions where intermediate states accumulate dynamically in memory, Shiny remains an elite, highly efficient tool for the job.

The Path Forward

Migrating StaffPuzzle proved that legacy technical debt is often compounded not by poor code, but by mismatched paradigms. By recognizing that we were attempting to solve a document-rendering and data-serving problem with a state-holding framework, we eliminated entire categories of redundant plumbing.

For teams evaluating similar migrations, the recommendation is clear: audit your state management before writing a single line of translation code. Ask the URL question early, embrace statelessness where appropriate, and leave the legacy observers behind.


Stay tuned for Episode 3 of our series, where we examine the financial and operational reality of transitioning away from managed hosting platforms—complete with real production numbers and invoices.