Path Helpers

Path Helpers

Path helpers in Juntos return callable objects with HTTP methods. This enables Server Functions-style data fetching where the same code works on both browser and server targets.

Table of Contents

Overview

Traditional Rails path helpers return URL strings:

articles_path        # => "/articles"
article_path(1)      # => "/articles/1"

Juntos path helpers return callable objects with HTTP methods:

articles_path.get()                      # GET /articles.json
articles_path.get(page: 2)               # GET /articles.json?page=2
articles_path.post(article: { title: "New" })  # POST /articles.json
article_path(1).patch(article: { ... })  # PATCH /articles/1.json
article_path(1).delete                   # DELETE /articles/1.json

All methods return native Response objects (browser) or synthetic equivalents that implement the same interface.

HTTP Methods

get(params)

Makes a GET request. Parameters become query string:

# Simple fetch
notes_path.get()
# => GET /notes.json

# With query parameters
notes_path.get(page: 2, per_page: 10)
# => GET /notes.json?page=2&per_page=10

# Search query
notes_path.get(q: "search term")
# => GET /notes.json?q=search+term

post(params)

Makes a POST request. Parameters become JSON body:

# Create new record
notes_path.post(note: { title: "Hello", body: "World" })
# => POST /notes.json
# => Content-Type: application/json
# => Body: {"note":{"title":"Hello","body":"World"}}

patch(params) / put(params)

Makes a PATCH or PUT request. Parameters become JSON body:

# Update existing record
note_path(1).patch(note: { title: "Updated" })
# => PATCH /notes/1.json
# => Body: {"note":{"title":"Updated"}}

delete(params)

Makes a DELETE request:

# Delete record
note_path(1).delete
# => DELETE /notes/1.json

Response Objects

All HTTP methods return a PathHelperPromise that wraps the response. This provides convenience methods for common patterns:

The .json, .text, .blob, and .arrayBuffer methods accept an optional block, providing a concise way to handle responses:

# Parse JSON response
notes_path.get.json do |data|
  setNotes(data)
end

# Create and use result
notes_path.post(note: params).json do |note|
  setNotes([note, *notes])
end

# Get text response
article_path(1).get(format: 'html').text do |html|
  setContent(html)
end

Full Response Access

When you need access to response status, headers, or conditional parsing, use .then:

# Check status before parsing
notes_path.post(note: params).then do |response|
  if response.ok
    response.json.then { |note| handleSuccess(note) }
  else
    response.json.then { |errors| handleErrors(errors) }
  end
end

Without Block

The convenience methods also work without a block, returning a promise that resolves directly to the parsed data:

# These return Promise<data> instead of Promise<Response>
data = await notes_path.get.json
text = await article_path(1).get(format: 'html').text

Response Methods

Method Returns Description
json() Promise Parse body as JSON
text() Promise Get body as text
blob() Promise Get body as Blob
arrayBuffer() Promise Get body as ArrayBuffer

Response Properties

Property Type Description
ok boolean True if status is 200-299
status number HTTP status code
statusText string HTTP status message
headers Headers Response headers

Format Parameter

Path helpers default to JSON format. Override with the format parameter:

# JSON (default)
notes_path.get()
# => GET /notes.json

# Explicit JSON
notes_path.get(format: 'json')
# => GET /notes.json

# HTML
notes_path.get(format: 'html')
# => GET /notes.html

# Turbo Stream
note_path(1).patch(note: updates, format: 'turbo_stream')
# => PATCH /notes/1.turbo_stream

The format also sets the appropriate Accept header:

Format Accept Header
json application/json
html text/html
turbo_stream text/vnd.turbo-stream.html

CSRF Protection

Mutating requests (POST, PATCH, PUT, DELETE) automatically include CSRF tokens:

// Token read from <meta name="csrf-token">
headers['X-Authenticity-Token'] = token

This works automatically—no configuration needed. Ensure your layout includes the CSRF meta tag:

<head>
  <%= csrf_meta_tags %>
</head>

Target-Specific Behavior

Path helpers work differently based on the build target:

Browser Target (Dexie, sql.js, etc.)

Path helpers invoke controllers directly:

  1. notes_path.get() is called
  2. Router matches path to NotesController.index
  3. Controller executes against local database (IndexedDB)
  4. Returns synthetic Response wrapping the result
notes_path.get() → Router.match() → Controller.index() → Synthetic Response

Server Target (Node.js, Cloudflare, etc.)

Path helpers make HTTP fetch requests:

  1. notes_path.get() is called
  2. Fetch request sent to /notes.json
  3. Server routes to controller, queries database
  4. Returns native Response object
notes_path.get() → fetch('/notes.json') → HTTP Response

Same Code, Different Runtime

This abstraction enables truly portable code:

# This exact code works on both targets
notes_path.get(q: searchQuery).json do |data|
  setNotes(data)
end

Backward Compatibility

Path helpers still work as strings when coerced:

# String coercion (backward compatible)
%x{ <a href={articles_path}>All Articles</a> }
navigate(article_path(article))

# Template literal
url = "#{articles_path}/archive"

The helpers implement toString() and valueOf() for seamless string conversion.

Controller Setup

For path helpers to return JSON, controllers must respond to JSON format:

class NotesController < ApplicationController
  def index
    @notes = Note.all

    respond_to do |format|
      format.html
      format.json { render json: @notes }
    end
  end

  def create
    @note = Note.new(note_params)

    respond_to do |format|
      if @note.save
        format.html { redirect_to @note }
        format.json { render json: @note, status: :created }
      else
        format.html { render :new }
        format.json { render json: @note.errors, status: :unprocessable_entity }
      end
    end
  end
end

Usage Patterns

Loading Data on Mount

export default def NotesList()
  notes, setNotes = useState([])
  loading, setLoading = useState(true)

  useEffect(-> {
    notes_path.get.json do |data|
      setNotes(data)
      setLoading(false)
    end
  }, [])

  # render...
end

Create with Optimistic Update

handleCreate = ->(params) {
  # Optimistic: add placeholder
  tempNote = { id: "temp", ...params }
  setNotes([tempNote, *notes])

  notes_path.post(note: params).json do |note|
    # Replace placeholder with real record
    setNotes(notes.map { |n| n.id == "temp" ? note : n })
  end
}

Update with Error Handling

When you need to check response status, use .then for full response access:

handleUpdate = ->(id, updates) {
  note_path(id).patch(note: updates).then do |response|
    if response.ok
      response.json.then do |updated|
        setNotes(notes.map { |n| n.id == id ? updated : n })
      end
    else
      response.json.then { |errors| setErrors(errors) }
    end
  end
}

For simpler cases where you just need the data:

handleUpdate = ->(id, updates) {
  note_path(id).patch(note: updates).json do |updated|
    setNotes(notes.map { |n| n.id == id ? updated : n })
  end
}

Delete with Confirmation

handleDelete = ->(id) {
  return unless confirm("Are you sure?")

  note_path(id).delete.then do |response|
    if response.ok
      setNotes(notes.filter { |n| n.id != id })
    end
  end
}

Pagination

loadPage = ->(page) {
  notes_path.get(page: page, per_page: 20).json do |data|
    setNotes(data)
  end
}

Search with Debounce

searchTimeout, setSearchTimeout = useState(nil)

handleSearch = ->(query) {
  clearTimeout(searchTimeout) if searchTimeout

  timeout = setTimeout(-> {
    notes_path.get(q: query).json do |data|
      setNotes(data)
    end
  }, 300)

  setSearchTimeout(timeout)
}

Turbo Stream Responses

For Turbo Stream responses, use the turbo_stream format:

handleUpdate = ->(id, updates) {
  note_path(id).patch(note: updates, format: 'turbo_stream').text do |html|
    Turbo.renderStreamMessage(html)
  end
}

The controller responds with Turbo Stream actions:

respond_to do |format|
  format.turbo_stream {
    render turbo_stream: turbo_stream.replace(dom_id(@note), @note)
  }
end

Generated Code

Path helpers are generated from config/routes.rb:

# config/routes.rb
Rails.application.routes.draw do
  resources :notes
end

Generates config/paths.js:

import { createPathHelper } from 'ruby2js-rails/path_helper.mjs';

function extract_id(obj) {
  return obj?.id ?? obj;
}

export function notes_path() {
  return createPathHelper('/notes');
}

export function note_path(note) {
  return createPathHelper(`/notes/${extract_id(note)}`);
}

export function new_note_path() {
  return createPathHelper('/notes/new');
}

export function edit_note_path(note) {
  return createPathHelper(`/notes/${extract_id(note)}/edit`);
}

Next Steps