Hotwire Discussion

Error Handling: Best Practices/Patterns?

Requesting some community advice on patterns and best practices for error handling when using Turbo, specifically when using TurboFrames.

To set the scene, say we look at a very basic “TODO List” app. You can image a Rails controller that pulls all the todos:

def index
  @todos = Todos.all
end

And a view that displays them in the app/views/toods/index.html.erb. This view allows one to mark a TODO as “done” by clicking a “done” button.

<ul>
  <% @todos.each do |todo| %>
    <%= render partial: 'todo', locals: { todo: todo } %>
  <% end %>
</ul>

the app/views/todos/_todo.html.erb partial:

<li>
  <%= turbo_frame_tag(dom_id(todo)) do %>
    <%= todo.title %>
    <% if todo.done? %>
      DONE
    <% else %>
      <%= form_with(model: todo, url: complete_todo_path(todo)) do |f| %>
        <%= f.button('Mark Done')
      <% end %>
    <% end %>
  <% end %>
<li>

The controller code that responds to the form POST (I realize I am not using respond_to here, but that’s for simplicity):

def complete
  todo = Todo.find(params[:id])

  todo.update_attribute :done, true

  render partial: 'todo', locals: { todo: todo }
end

If all goes well, the partial gets rendered, now with the TODO in the “done” state, Turbo receives that response, sees the turbo_frame tag with the matching id and renders the response into the frame that exists on the page. Great!

But what about 5xx errors? 404s? Are there patterns or best practices around how we should deliver these errors to clients? 422s are pretty easy to handle and Turbo can now simply re-render those (for validations, let’s say).

For example, say the above controller method throws a 500 for some reason. What happens on the front end? The user actually sees the TODO disappear. Why? Because Rails returns a 500 error trace page in development, and in production serves a generic 500 server error response. Neither of those responses contain a turbo_frame with the expected id of the todo that caused the issue. The Javascript console tells us this with a thrown error.

I’ve considered several patterns to address this, but none exactly seem “right”:

  1. Attach a stimulus controller to the body like this (layout here):
<body data-controller="errors" data-action="turbo:before-fetch-response->errors#checkStatus">
  <%= yield %>
</body>

And the stimulus controller. Obviously you’d probably want to do something other than use an alert box, maybe a nicely styled modal or something, but you get the idea:

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  checkStatus(e) {
    const { status } = e.detail.fetchResponse.response;

    if (status >= 500 && status < 600) {
      alert(`${status} Error. Howdy, the server is bonkers. Try again or reload the page?`);
    }
  }
}

This works, but we still have the issue that Turbo removed the turbo_frame from the page. Granted, since a 500 error occurred, we don’t really know what the state of that todo is in the backend, but, it is also surprising that it disappeared. Perhaps instead of an alertbox, one could use a modal that is simply not closeable. It is essentially “500 Error, reload the page. There is nothing else you can do. We just don’t know the state of the backend, so refresh it yourself user!”. This what would happen if we weren’t using Turbo or SPA or SPA-like libraries, the user would click a button, the form would post and an ugly 500 error would get displayed.

(Additionally, in the current state of things, turbo:before-fetch-response is not going to get triggered on, say, NetworkErrors since in that case the fetch has not even happened yet. OK, so we could listen for turbo:submit-start and we should be able to suss out a network error there, but Turbo only fires turbo:submit-start on POST requests. However, With the following pull request: `GET` Forms: fire `submit-start` and `submit-end` by seanpdoyle · Pull Request #424 · hotwired/turbo · GitHub we’ll be able to also listen to turbo:submit-start for POST and GET requests, so at least we can cover network errors in the above way, just with maybe a new stimulus method and another action on the body.)

  1. The above works, but there’s that pesky issue about the todo disappearing. So… what about this in our controller (again, for simplicity I’m not including respond_to blocks here):
def complete
  todo = Todo.find(params[:id])

  todo.update_attribute :done, true

  render partial: 'todo', locals: { todo: todo }
rescue ActiveRecord::RecordNotFound
  render turbo_stream: [
    turbo_stream.replace(dom_id(todo), partial: 'todo', locals: { todo: todo }),
    turbo_stream.update('error', partial: 'shared/not_found_error')
  ]
rescue
  render turbo_stream: [
    turbo_stream.replace(dom_id(todo), partial: 'todo', locals: { todo: todo }),
    turbo_stream.update('error', partial: 'shared/server_error')
  ]
end

In the above, we are responding with multiple streams fragments to:

A) Update the todo’s turbo frame with the given todo, so the user will see the state that it is in (instead of it disappearing).
B) Imagine the layout has a div with id “error” and a Stimulus controller attached to it. Perhaps that stimulus controller can pop a modal or some other UI element appears once the “error” element has new content (which it will once Turbo attaches that stream fragment in the correct place in the DOM).

This (in a way) feels more like “the Hotwire/Turbo way” (if there is a such a thing): everything is rendered on the server.

However, what a pain to catch all the possible errors and respond to them in every controller method in a Turbo-ified application. There’s some fancy footwork that can be done to DRY it out a bit, for sure. for example at the top of our ApplicationController we could use something like this:

class ApplicationController < ActionController::Base
  rescue_from ActiveRecord::RecordNotFound, with: :render_404
  rescue_from Exception, with: :render_500

  def render_404(e)
    respond_to do |format|
      format.turbo_stream turbo_stream.update('error', partial: 'shared/not_found_error')
      format.any { raise e }
    end
  end

  def render_500(e)
    respond_to do |format|
      format.turbo_stream turbo_stream.update('error', partial: 'shared/server_error')
      format.any { raise e }
    end
  end
end

Now, I haven’t tested that code, but by re-raising e if not a turbo_stream, I assume the Rails stack will then just handle it as per normal.

Now, this little DRY out doesn’t really address the fact that we are no longer responding with the TurboStream fragment to re-render our TODO, there are ways to figure that out though. We could still rescue in our controllers, and set a controller instance variable like @additional_error_streams to an array of streams to by rendered by the render_404/500 methods, perhaps.

One flaw: What if you’ve got a proxy in your infrastructure and your Rails app is down when a request is made? In this case, NGINX or Cloudflare or your front-line server is going to through a “502” bad gateway… and now our client is back in the same boat: Turbo is going to throw a JS error that says the response doesn’t contain the expected turbo_frame id, the TODO will disappear and the user sees nothing.

So, it seems like some combination of things might be best? What are others doing to maintain a good user experience during 50x/404 errors? I don’t want pieces of the UI to disappear, and I want the user to be notified when things go wrong for any variety of reasons.

I think this PR will be the solution? Introduce `turbo:frame-missing` event by seanpdoyle · Pull Request #445 · hotwired/turbo · GitHub