Skip to content

"use client" Directive

React Server Components split the UI into two worlds: server components (your ERB templates) run on the server, and client components (files with "use client") run in the browser. ruact makes this boundary explicit and easy to manage.


What "use client" does

When you add "use client" as the first statement in a .jsx (or .tsx) file, you are declaring that this file — and everything it imports — runs in the browser.

jsx
"use client"  // ← must be the very first line

import { useState } from "react"

export function Counter({ initialCount }) {
  const [count, setCount] = useState(initialCount)

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  )
}

This component can use useState, useEffect, event handlers, and browser APIs. Server components cannot.


Where to put client components

Place all "use client" files in app/javascript/components/:

app/javascript/
└── components/
    ├── Counter.jsx           ← "use client"
    ├── LikeButton.jsx        ← "use client"
    └── SearchInput.jsx       ← "use client"

app/javascript/ is just your components — ruact's bootstrap entry and Flight runtime are hidden plumbing (a virtual module served from the gem), not files in your tree. See Getting started for the full picture.

The Vite plugin scans this directory (and any configured paths) for "use client" directives and registers them in the client manifest (public/react-client-manifest.json).

PascalCase exports only. The manifest scanner looks for named exports that start with a capital letter. export function Counter is registered; export function helper is not.

Auto-registration (zero edits)

The browser also needs a map from each manifest entry to its module — the registry the Flight client uses to turn a server-sent component reference back into the real component. You never hand-maintain this. The same Vite plugin scan emits a virtual module, virtual:ruact/registry, whose default export is exactly that map, keyed by the manifest id. ruact's hidden bootstrap entry imports it once:

jsx
// virtual:ruact/bootstrap (served from the gem — not a file in your app)
import MODULE_REGISTRY from 'virtual:ruact/registry'

Add a "use client" component to app/javascript/components/ and it is registered automatically — zero edits, and nothing for you to import. Because the registry is keyed on the same manifest id the server sends, lookups match in both dev and production (the component is bundled into your app, so the id stays stable across builds). To opt a component out of registration, place it outside app/javascript/components/ or drop its "use client" directive.


Using client components in ERB

Once a component is registered in the manifest, use it by PascalCase tag in any ERB template:

erb
<!-- The component name must match the export name exactly -->
<Counter initialCount={@post.view_count} />

ruact resolves Counter → the manifest entry → the JavaScript module path. The browser loads the module and renders the component with the props you passed from Ruby.


Component contracts

By default a <Component .../> call site is unchecked: pass a typo'd prop name or forget a required one and the only symptom is a silent undefined in the browser — discovered at runtime, in production. A component contract turns that into an error at preprocess time, at the call site (file and line), before the page ever renders.

A component opts in by exporting __ruactContract from its own module — the declaration lives next to the component (this is the HEEx attr/slot idea, ported to ruact):

tsx
"use client"

export const __ruactContract = {
  props: {
    postId: "required",
    initialCount: "optional",
  },
  slots: { header: "optional" },   // optional; { name: "required" | "optional" } or ["name"]
  passthrough: false,              // optional; set true to allow undeclared props
}

export function LikeButton({ postId, initialCount = 0 }) {
  // ...
}

Now a call site that breaks the contract fails the build / template compile with a message that names the component, the file:line, the offending prop, and a "did you mean?" suggestion:

erb
<%# app/views/posts/show.html.erb %>
<LikeButton initialCount={5} />
<%#  → ruact: <LikeButton> at app/views/posts/show.html.erb:7
       is missing required prop "postId" — add the required prop "postId". %>

<LikeButton postId={@post.id} postID={1} />
<%#  → ruact: <LikeButton> at app/views/posts/show.html.erb:9
       got unknown prop "postID" — did you mean "postId"?. %>

A few things worth knowing:

  • Opt-in. A component that exports no __ruactContract is validated not at all and emits byte-identical output — adding contracts to one component never touches the others.
  • Name-level only. The check validates prop names, presence (required/optional), and slot names. It does not check prop valuespostId={@post.id} is an arbitrary Ruby expression evaluated at render time, invisible to the preprocessor. (Value typing belongs to the server boundary — see typed query accessors.)
  • Slots are name-level today: a slot is passed as a named prop attribute (<Card header={...} />), and a required slot must be present. Deeper slot semantics are a future addition.
  • One component per file. A __ruactContract describes the file's single component. If a file exports more than one PascalCase component, the contract can't say which one it belongs to, so it is ignored (with a build warning) — keep contracted components one per file.

Co-location vs shared components

Co-located pattern — the client component is specific to one feature:

app/javascript/components/
└── posts/
    └── LikeButton.jsx    # only used in post views

Shared pattern — the component is used across multiple features:

app/javascript/components/
├── ui/
│   ├── Button.jsx        # used everywhere
│   └── Modal.jsx         # used everywhere
└── posts/
    └── LikeButton.jsx

Both patterns work. The Vite plugin scans all .jsx/.tsx files under components/ recursively.


What cannot be a server component

Anything that requires:

  • useState, useReducer, useContext
  • useEffect, useLayoutEffect
  • Browser APIs (window, document, navigator)
  • Event handlers (onClick, onChange, onSubmit)
  • Third-party libraries that use the above

Move these to a "use client" component and pass the necessary data as props from your ERB server component.


How the manifest works

When Vite builds your project, the plugin:

  1. Scans app/javascript/components/ for "use client" files
  2. Extracts PascalCase-named exports
  3. Writes public/react-client-manifest.json:
json
{
  "Counter": {
    "moduleId": "/components/Counter.jsx",
    "exportName": "Counter",
    "chunks": ["/assets/Counter-abc123.js"]
  }
}

When ruact encounters <Counter /> in ERB, it calls ClientManifest#reference_for("Counter") to look up the module ID and chunks. This information is serialized as an import row in the Flight payload so the browser knows which JavaScript module to load.

In development, the manifest is regenerated on every file save via the Vite dev server's configureServer hook.

Released under the MIT License.