State persistence
Two components remember a choice across requests: the theme switcher (theme and light/dark mode) and the sidebar (expanded or collapsed). Both persist in a cookie by default and render server-side, so nothing flashes on load. Both can persist on your backend instead, with the cookie as the fallback.
How it works
- Read side — a helper renders the state into the HTML. It takes the value as an argument and falls back to the cookie when the argument is
nil. Precedence: argument, then cookie, then default. - Write side — the component always writes its cookie, then dispatches a Stimulus
changeevent with the new value. The gem never issues a request; your app listens if it wants to store the value. - Failure — if your request fails, the cookie keeps the page correct until the next server render, where your stored value wins.
Theme and mode
Cookie (default). Put the helper on <html>; the switcher writes the shadcnrb_theme and shadcnrb_mode cookies:
<html class="<%= sui.theme_class %>">
…
<%= sui.theme_switcher %>
Backend. Pass the stored values; the event detail is always { theme, mode }, whichever one changed:
<html class="<%= sui.theme_class(theme: current_user.theme, mode: current_user.mode) %>">
shadcnrb--theme-switcher--component:change detail: { theme: "blue", mode: "dark" }
Sidebar
Cookie (default). The wrapper reads the sidebar_state cookie, which a desktop toggle writes:
<%= sui.sidebar_wrapper do |s| %>
Backend. Pass the stored state (:expanded or :collapsed); only a desktop toggle dispatches, never the mobile drawer or a resize:
<%= sui.sidebar_wrapper state: current_user.sidebar_state do |s| %>
shadcnrb--sidebar--component:change detail: { state: "collapsed" }
The backend hook
One Stimulus controller on any ancestor catches both events, here the layout's body. This site runs exactly this, with the session as its store:
<body data-controller="preferences"
data-preferences-url-value="<%= preferences_path %>"
data-action="shadcnrb--theme-switcher--component:change->preferences#save
shadcnrb--sidebar--component:change->preferences#save">
// app/javascript/controllers/preferences_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { url: String } save({ detail }) { fetch(this.urlValue, { method: "PATCH", headers: { "Content-Type": "application/json", "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content }, body: JSON.stringify(detail) }) } }
# config/routes.rb patch "preferences" => "preferences#update" # app/controllers/preferences_controller.rb class PreferencesController < ApplicationController def update current_user.update!(params.permit(:theme, :mode).merge(sidebar_state: params[:state]).compact) head :no_content end end