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 change event 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