Skip to content

Components & JSX

Remix V3 includes a lightweight component system for building HTML pages using JSX. This guide covers the component model in depth --- from basic syntax to server rendering, client-side hydration, and streaming.

If you are new to components, start with Part 3 of the tutorial. This guide goes deeper into every aspect of the system.

What is JSX?

JSX (JavaScript XML) is a syntax extension that lets you write HTML-like code inside JavaScript files. It looks like HTML but is actually JavaScript:

tsx
let element = <h1 class="title">Hello, world!</h1>

This gets transformed by a compiler (TypeScript, Babel, or the runtime's built-in loader) into function calls:

js
let element = jsx('h1', { class: 'title', children: 'Hello, world!' })

JSX is not HTML --- it is a convenient way to describe a tree of elements. The compiler transforms it into plain JavaScript at build time.

File extensions

Files containing JSX must use the .tsx extension (instead of .ts). This tells the compiler that the file contains JSX syntax and needs to be transformed.

Configuring JSX

Remix uses its own JSX runtime. Configure it in your tsconfig.json:

json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "remix/component"
  }
}

The jsxImportSource tells TypeScript to use Remix's JSX runtime instead of React's. You do not need React installed.

Remix's Component Model

Remix's component model is fundamentally different from React:

  • No virtual DOM --- On the server, components render directly to HTML strings or streams. There is no virtual DOM diffing.
  • Lightweight client runtime --- On the client, Remix provides a small runtime for interactivity, not a full reconciliation engine.
  • Double function pattern --- Components are functions that return render functions.

This design makes server rendering fast and keeps the client bundle small.

Component Structure

A Remix component is a function that returns a render function. The outer function creates the component; the inner function renders it with specific props:

tsx
function Greeting() {
  return ({ name }: { name: string }) => (
    <p>Hello, {name}!</p>
  )
}

Breaking this down:

  1. function Greeting() --- The component function. Must start with a capital letter.
  2. return ({ name }) => ... --- Returns a render function that receives props.
  3. <p>Hello, {name}!</p> --- The JSX that becomes HTML.

Use the component like an HTML tag:

tsx
<Greeting name="World" />
// Renders: <p>Hello, World!</p>

Why the Double Function?

The outer function is the setup phase --- it runs once when the component is created. The inner function is the render phase --- it runs each time the component needs to produce output. This separation supports:

  • Server-side streaming (the setup phase can begin before all data is ready)
  • Client-side updates (the render function can be called again with new props)
  • Resource initialization (open connections, create state, etc. in the setup phase)

Props

Props (short for "properties") are the data you pass to a component, just like HTML attributes:

tsx
<BookCard title="Ash & Smoke" price={16.99} inStock={true} />

Define props using a TypeScript interface:

tsx
interface BookCardProps {
  title: string
  price: number
  inStock: boolean
  genre?: string  // Optional prop
}

function BookCard() {
  return ({ title, price, inStock, genre = 'General' }: BookCardProps) => (
    <div class="book-card">
      <h3>{title}</h3>
      <p>${price.toFixed(2)}</p>
      <p>{genre}</p>
      {inStock ? <span class="badge">In Stock</span> : null}
    </div>
  )
}

Prop Types

Props can be any JavaScript value:

TypeExample
String<Comp name="Alice" />
Number<Comp count={42} />
Boolean<Comp active={true} /> or <Comp active />
Object<Comp user={{ name: 'Alice' }} />
Array<Comp items={[1, 2, 3]} />
Function<Comp onClick={() => console.log('clicked')} />
JSX<Comp icon={<Icon />} />

The children Prop

The children prop is special --- it represents content placed between the opening and closing tags:

tsx
<Layout title="Home">
  <h1>Welcome!</h1>
  <p>This is the children content.</p>
</Layout>

In the component:

tsx
import type { RemixNode } from 'remix/component'

interface LayoutProps {
  title: string
  children?: RemixNode
}

function Layout() {
  return ({ title, children }: LayoutProps) => (
    <html>
      <head><title>{title}</title></head>
      <body>{children}</body>
    </html>
  )
}

RemixNode is the type for anything renderable: strings, numbers, JSX elements, arrays of these, or null/undefined.

The Handle API

The Handle is an object available inside the setup phase of a component. It provides access to the component's lifecycle and context:

tsx
function Counter() {
  return (handle) => {
    let count = 0

    return ({ initial = 0 }: { initial?: number }) => {
      count = initial

      return (
        <div>
          <p>Count: {count}</p>
          <button mix={on('click', () => {
            count++
            handle.update()
          })}>
            Increment
          </button>
        </div>
      )
    }
  }
}

Three-level nesting for the Handle

When using the Handle API, the component has three levels of nesting:

  1. The component function (setup)
  2. A function that receives the handle
  3. The render function that receives props

The handle is only needed for interactive, client-side components.

handle.update()

Triggers a re-render of the component with the current state. This is how you update the UI in response to user interactions:

tsx
handle.update()

handle.queueTask()

Schedules a task to run asynchronously. Useful for debouncing or batching updates:

tsx
handle.queueTask(() => {
  // This runs after the current execution completes
  fetchData().then((data) => {
    items = data
    handle.update()
  })
})

handle.signal

An AbortSignal that is aborted when the component is destroyed. Use it to cancel in-flight requests:

tsx
function DataLoader() {
  return (handle) => {
    let data = null

    handle.queueTask(async () => {
      let response = await fetch('/api/data', {
        signal: handle.signal,
      })
      data = await response.json()
      handle.update()
    })

    return () => (
      <div>{data ? JSON.stringify(data) : 'Loading...'}</div>
    )
  }
}

handle.id

A unique identifier for the component instance. Useful for generating unique DOM IDs:

tsx
return (handle) => {
  return ({ label }: { label: string }) => (
    <div>
      <label for={`input-${handle.id}`}>{label}</label>
      <input id={`input-${handle.id}`} />
    </div>
  )
}

handle.context

Access the component tree's context (see Context API below).

Fragments

When you need to return multiple elements without a wrapping container, use a fragment:

tsx
function BookMeta() {
  return ({ author, year }: { author: string; year: number }) => (
    <>
      <p>Author: {author}</p>
      <p>Year: {year}</p>
    </>
  )
}

The <>...</> syntax creates a fragment --- a grouping mechanism that does not produce any HTML element in the output.

Mixins

Mixins are reusable units of behavior and styling that you attach to elements using the mix attribute. They are one of Remix's most powerful features.

css() --- Inline Styles

tsx
import { css } from 'remix/component'

<div mix={css({ padding: '1rem', backgroundColor: '#f0f0f0' })}>
  Styled content
</div>

CSS properties use camelCase (e.g. backgroundColor, not background-color).

on() --- Event Listeners

tsx
import { on } from 'remix/component'

<button mix={on('click', () => {
  console.log('Button clicked!')
})}>
  Click Me
</button>

Common events: 'click', 'input', 'change', 'submit', 'keydown', 'focus', 'blur', 'mouseenter', 'mouseleave'.

tsx
import { link } from 'remix/component'

<a mix={link('/books')}>Browse Books</a>

The link() mixin enables client-side navigation without a full page reload when the component system is hydrated on the client.

ref() --- DOM References

tsx
import { ref } from 'remix/component'

function FocusInput() {
  return (handle) => {
    let inputRef = ref<HTMLInputElement>()

    return () => (
      <div>
        <input mix={inputRef} />
        <button mix={on('click', () => {
          inputRef.current?.focus()
        })}>
          Focus Input
        </button>
      </div>
    )
  }
}

pressEvents --- Press Handling

tsx
import { pressEvents } from 'remix/component'

<button mix={pressEvents({
  onPress: () => console.log('pressed'),
  onPressStart: () => console.log('press start'),
  onPressEnd: () => console.log('press end'),
})}>
  Press Me
</button>

pressEvents provides normalized press handling that works across mouse, touch, and keyboard interactions.

keysEvents --- Keyboard Shortcuts

tsx
import { keysEvents } from 'remix/component'

<div mix={keysEvents({
  'Escape': () => closeModal(),
  'Enter': () => submit(),
  'Ctrl+S': () => save(),
})}>
  Press Escape to close
</div>

Combining Mixins

Pass an array of mixins to combine them:

tsx
<button mix={[
  css({ padding: '0.5rem 1rem', background: 'blue', color: 'white' }),
  on('click', handleClick),
  pressEvents({ onPress: handlePress }),
]}>
  Interactive Button
</button>

Server Rendering

Remix provides two functions for rendering components to HTML on the server.

renderToString()

Renders the entire component tree to a single HTML string:

tsx
import { renderToString } from 'remix/component/server'

let html = renderToString(
  <Layout title="Home">
    <h1>Welcome</h1>
  </Layout>,
)

return new Response(html, {
  headers: { 'Content-Type': 'text/html; charset=UTF-8' },
})

renderToString is simple and works well for small to medium pages.

renderToStream()

Renders the component tree progressively, sending chunks of HTML as they become ready:

tsx
import { renderToStream } from 'remix/component/server'

let stream = renderToStream(
  <Layout title="Home">
    <h1>Welcome</h1>
    <BookList />
  </Layout>,
)

return new Response(stream, {
  headers: { 'Content-Type': 'text/html; charset=UTF-8' },
})

renderToStream returns a ReadableStream that you can pass directly to a Response. The browser starts rendering HTML as soon as it arrives, which improves time-to-first-byte (TTFB) and perceived performance.

When to use streaming

Use renderToStream when:

  • Your page loads data from a database or API (the browser can show the shell while data loads)
  • Your page is large (users see content sooner)
  • You want the best possible perceived performance

Use renderToString when:

  • Your page is small and renders quickly
  • You need the full HTML as a string (e.g., for caching or email)

Client-Side Hydration

Server-rendered HTML is static --- clicking buttons does nothing until JavaScript loads and hydrates the page. Hydration connects the server-rendered HTML to the client-side component runtime.

The run() Function

ts
import { run } from 'remix/component/client'

run(document.getElementById('app')!, App)

The run() function:

  1. Finds the server-rendered HTML in the given DOM element.
  2. Attaches event listeners and state management to the existing DOM.
  3. Makes the page interactive.

Client Entry

A typical client entry file:

ts
import { run } from 'remix/component/client'
import { App } from './ui/app.tsx'

run(document.body, App)

Include this script in your HTML document:

tsx
function Document() {
  return ({ children }: { children?: RemixNode }) => (
    <html>
      <head>
        <title>My App</title>
      </head>
      <body>
        {children}
        <script type="module" src="/app/client-entry.ts" />
      </body>
    </html>
  )
}

The Frame Component

The Frame component enables streaming partial server-rendered UI into a page. It acts as a boundary that can be loaded independently:

tsx
import { Frame } from 'remix/component'

function BookPage() {
  return ({ bookId }: { bookId: string }) => (
    <Layout>
      <h1>Book Details</h1>
      <Frame src={`/api/books/${bookId}/details`}>
        <p>Loading book details...</p>
      </Frame>
    </Layout>
  )
}

The Frame component:

  • Renders the children immediately as a placeholder
  • Fetches the content from src in the background
  • Replaces the placeholder with the fetched content when it arrives

This is useful for:

  • Loading slow data without blocking the rest of the page
  • Lazy-loading sections of a page
  • Building dashboard-style layouts where each widget loads independently

Context API

The Context API lets you pass data through the component tree without explicitly threading it through every level of props:

tsx
import { createContext, useContext } from 'remix/component'

// Create a context with a default value
let ThemeContext = createContext('light')

// Provider component
function ThemeProvider() {
  return ({ theme, children }: { theme: string; children?: RemixNode }) => (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>
  )
}

// Consumer component
function ThemedButton() {
  return (handle) => {
    let theme = handle.context.get(ThemeContext)

    return ({ label }: { label: string }) => (
      <button class={`btn-${theme}`}>{label}</button>
    )
  }
}

// Usage
<ThemeProvider theme="dark">
  <ThemedButton label="Click Me" />
</ThemeProvider>

The context value is available to all descendant components without passing it as a prop through intermediate components.

Animation

Remix includes animation utilities for smooth UI transitions:

spring()

tsx
import { spring } from 'remix/component'

let position = spring(0, { stiffness: 300, damping: 30 })

// Update the spring target
position.set(100)

// Use in styles
<div mix={css({ transform: `translateX(${position.value}px)` })}>
  Animated
</div>

tween()

tsx
import { tween, easings } from 'remix/component'

let opacity = tween(0, {
  duration: 300,
  easing: easings.easeInOut,
})

opacity.set(1)

<div mix={css({ opacity: opacity.value })}>
  Fade In
</div>

Easings

The easings object provides standard easing functions:

  • easings.linear
  • easings.easeIn, easings.easeOut, easings.easeInOut
  • easings.easeInCubic, easings.easeOutCubic, easings.easeInOutCubic
  • And more.

Rendering Lists

Use JavaScript's .map() to render arrays:

tsx
function BookList() {
  return ({ books }: { books: Book[] }) => (
    <ul>
      {books.map((book) => (
        <li>{book.title} by {book.author}</li>
      ))}
    </ul>
  )
}

Conditional Rendering

Use ternary expressions or logical AND:

tsx
// Ternary: show one thing or another
{isLoggedIn ? <UserMenu /> : <LoginButton />}

// Logical AND: show something only if true
{errorMessage ? <p class="error">{errorMessage}</p> : null}

// Nullish values (null, undefined, false) render nothing
{showBanner && <Banner />}

Released under the MIT License.