Requesting some community advice on patterns and best practices for error handling 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/todos/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 the form POST (I am not using “respond_to” blocks here 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 renders, 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 access and then expose these errors to users? 422s are pretty easy to handle and Turbo can now simply re-render those (for validations, let’s say).
For example, say the above complete controller method throws a 500 for some reason. The user actually sees the TODO disappear and receives no other feedback. Why? Because rails returns a 500 error tracedump in development, and in production serves a generic 500 server error response. Neither of those responses contain a turbo_frame with the expected dom_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”:
- 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 closeeable. 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!”.
(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.)
- 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 resonding 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 backend Rails app is down when a request is made? In this case, NGINX or Cloudflare or your frontline 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.