Skip to content

Component Tutorial

In this tutorial you will build three things:

  1. An interactive counter --- learn the double-function pattern, state, and event handling.
  2. A todo list --- learn the css mixin, lists, and conditional rendering.
  3. A streaming page --- learn server rendering, client hydration, and the Frame component.

Prerequisites

Set up your tsconfig.json so TypeScript knows to use the Remix JSX runtime:

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

All imports in this tutorial come from the remix meta-package:

ts
import { css, on, ref, Fragment, Frame } from 'remix/component'
import { renderToString, renderToStream } from 'remix/component/server'
import { run, createRoot } from 'remix/component'

Part 1: Interactive Counter

Step 1 --- A Static Component

Start with a component that displays a number. The outer function is the setup phase; the inner function is the render phase.

tsx
function Counter() {
  // Setup: runs once when the component is created
  return ({ initial }: { initial: number }) => (
    // Render: runs each time props change
    <div>
      <p>Count: {initial}</p>
    </div>
  )
}

Use it like any JSX element:

tsx
<Counter initial={0} />

Step 2 --- Adding State with the Handle

To make the counter interactive, add a handle parameter. The handle gives you update(), which schedules a re-render.

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

    return ({ initial = 0 }: { initial?: number }) => {
      // On each render, sync with the prop
      count = initial

      return (
        <div>
          <p>Count: {count}</p>
        </div>
      )
    }
  }
}

What is handle.update()?

It tells Remix "something changed --- please re-render this component." It returns a Promise<AbortSignal> that resolves after the update is painted.

Step 3 --- Handling Click Events

Use the on mixin to attach a DOM event listener. Mixins are passed through the mix attribute.

tsx
import { on } from 'remix/component'

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

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

Every time the user clicks a button, count changes and handle.update() triggers a re-render that reflects the new value.

Step 4 --- Styling with the css Mixin

The css mixin applies scoped inline styles. Properties use camelCase.

tsx
import { css, on } from 'remix/component'

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

    let containerStyle = css({
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      fontFamily: 'sans-serif',
    })

    let buttonStyle = css({
      padding: '0.25rem 0.75rem',
      fontSize: '1rem',
      cursor: 'pointer',
      ':hover': {
        background: '#e0e0e0',
      },
    })

    return () => (
      <div mix={containerStyle}>
        <button mix={[buttonStyle, on('click', () => { count--; handle.update() })]}>-</button>
        <span mix={css({ minWidth: '3rem', textAlign: 'center' })}>{count}</span>
        <button mix={[buttonStyle, on('click', () => { count++; handle.update() })]}>+</button>
      </div>
    )
  }
}

Combining mixins

Pass an array to mix to combine multiple mixins on the same element: mix={[css({...}), on('click', handler)]}.


Part 2: Todo List

Step 1 --- Data Model

Define a Todo type and keep the list in a plain array:

tsx
interface Todo {
  id: number
  text: string
  done: boolean
}

Step 2 --- The TodoList Component

tsx
import { css, on } from 'remix/component'

function TodoList() {
  return (handle) => {
    let todos: Todo[] = []
    let nextId = 1

    function addTodo(text: string) {
      todos.push({ id: nextId++, text, done: false })
      handle.update()
    }

    function toggleTodo(id: number) {
      let todo = todos.find((t) => t.id === id)
      if (todo) {
        todo.done = !todo.done
        handle.update()
      }
    }

    function removeTodo(id: number) {
      todos = todos.filter((t) => t.id !== id)
      handle.update()
    }

    return () => (
      <div mix={css({ maxWidth: '400px', margin: '2rem auto', fontFamily: 'sans-serif' })}>
        <h2>Todo List</h2>
        <TodoInput onAdd={addTodo} />
        <ul mix={css({ listStyle: 'none', padding: 0 })}>
          {todos.map((todo) => (
            <TodoItem
              key={todo.id}
              todo={todo}
              onToggle={() => toggleTodo(todo.id)}
              onRemove={() => removeTodo(todo.id)}
            />
          ))}
        </ul>
        {todos.length === 0 && (
          <p mix={css({ color: '#888' })}>No todos yet. Add one above.</p>
        )}
      </div>
    )
  }
}

Step 3 --- The Input Component

This component manages its own local state for the text field. The ref mixin gives direct access to the DOM node.

tsx
import { css, on, ref } from 'remix/component'

function TodoInput() {
  return (handle) => {
    let inputEl: HTMLInputElement | null = null

    function handleSubmit(onAdd: (text: string) => void) {
      if (inputEl && inputEl.value.trim()) {
        onAdd(inputEl.value.trim())
        inputEl.value = ''
      }
    }

    return ({ onAdd }: { onAdd: (text: string) => void }) => (
      <form mix={[
        css({ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }),
        on('submit', (e) => {
          e.preventDefault()
          handleSubmit(onAdd)
        }),
      ]}>
        <input
          type="text"
          placeholder="What needs to be done?"
          mix={[
            css({ flex: 1, padding: '0.5rem', fontSize: '1rem' }),
            ref((el) => { inputEl = el }),
          ]}
        />
        <button type="submit" mix={css({
          padding: '0.5rem 1rem',
          background: '#0066ff',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: 'pointer',
        })}>
          Add
        </button>
      </form>
    )
  }
}

Step 4 --- The Item Component

Each item shows a checkbox, the text, and a remove button. Completed items get a strikethrough style.

tsx
import { css, on } from 'remix/component'

function TodoItem() {
  return ({ todo, onToggle, onRemove }: {
    todo: Todo
    onToggle: () => void
    onRemove: () => void
  }) => (
    <li mix={css({
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      padding: '0.5rem 0',
      borderBottom: '1px solid #eee',
    })}>
      <input
        type="checkbox"
        checked={todo.done}
        mix={on('change', onToggle)}
      />
      <span mix={css({
        flex: 1,
        textDecoration: todo.done ? 'line-through' : 'none',
        color: todo.done ? '#999' : '#333',
      })}>
        {todo.text}
      </span>
      <button mix={[
        css({ background: 'none', border: 'none', color: '#cc0000', cursor: 'pointer' }),
        on('click', onRemove),
      ]}>
        Remove
      </button>
    </li>
  )
}

Static vs. interactive components

TodoItem has no handle --- it is a static component. The outer function returns the render function directly. It re-renders only when its parent passes new props.


Part 3: Streaming Page with Server Rendering

Step 1 --- A Layout Component

Create a layout that wraps every page:

tsx
import { css } from 'remix/component'

function Layout() {
  return ({ title, children }: { title: string; children: any }) => (
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>{title}</title>
      </head>
      <body mix={css({
        margin: 0,
        fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
        lineHeight: 1.6,
      })}>
        <header mix={css({
          background: '#0066ff',
          color: 'white',
          padding: '1rem 2rem',
        })}>
          <h1 mix={css({ margin: 0, fontSize: '1.25rem' })}>{title}</h1>
        </header>
        <main mix={css({ padding: '2rem', maxWidth: '800px', margin: '0 auto' })}>
          {children}
        </main>
      </body>
    </html>
  )
}

Step 2 --- Rendering to a String

Use renderToString when you need the complete HTML as a single value. This is useful for small pages, caching, and email.

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

function handleRequest(request: Request): Response {
  let html = renderToString(
    <Layout title="Home">
      <p>Welcome to the site.</p>
    </Layout>
  )

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

Step 3 --- Rendering to a Stream

Use renderToStream for large pages. The browser starts painting HTML as chunks arrive, improving time-to-first-byte.

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

function handleRequest(request: Request): Response {
  let stream = renderToStream(
    <Layout title="Dashboard">
      <p>Loading your data...</p>
      <Frame src="/api/dashboard/stats" fallback={<p>Fetching stats...</p>} />
    </Layout>,
    {
      frameSrc: request.url,
      onError: (error) => console.error('Render error:', error),
    },
  )

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

What is a Frame?

The Frame component is a page region that loads its content from a URL. During streaming SSR, the server resolves the frame's src and streams its HTML inline. On the client, frames can reload or navigate independently.

Step 4 --- The Frame Component

Frames let you split a page into independently-loadable sections. Each frame has a src URL and an optional fallback:

tsx
import { Frame } from 'remix/component'

function Dashboard() {
  return () => (
    <div>
      <h2>Dashboard</h2>

      <section>
        <h3>Recent Activity</h3>
        <Frame
          src="/api/dashboard/activity"
          name="activity"
          fallback={<p>Loading activity...</p>}
        />
      </section>

      <section>
        <h3>Statistics</h3>
        <Frame
          src="/api/dashboard/stats"
          name="stats"
          fallback={<p>Loading stats...</p>}
        />
      </section>
    </div>
  )
}

The server resolves each frame's content during streaming. The name attribute lets you target frames for client-side navigation later.

Step 5 --- Client Hydration

On the client, call run() to attach interactivity to the server-rendered HTML. This does not re-render the page --- it walks the existing DOM and binds event listeners.

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

let app = run({
  loadModule: (url, name) => import(url).then((m) => m[name]),
  resolveFrame: async (src) => {
    let response = await fetch(src, {
      headers: { Accept: 'text/html-partial' },
    })
    return response.body
  },
})

app.addEventListener('error', (event) => {
  console.error('Component error:', event.error)
})

await app.ready()

What is hydration?

Hydration is the process of attaching JavaScript event listeners and state to HTML that was already rendered on the server. The user sees the page immediately (server HTML), and interactivity activates once the JavaScript loads.

Step 6 --- Client-Only Rendering with createRoot

For widgets that do not need server rendering, use createRoot to render directly into a DOM element:

ts
import { createRoot } from 'remix/component'

let root = createRoot(document.getElementById('widget'))
root.render(<Counter />)

// To unmount later:
root.dispose()

Putting It All Together

Here is a complete server handler that renders the todo list with streaming and hydration:

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

// Mark the todo list as a client entry so it hydrates on the client
export let InteractiveTodoList = clientEntry(
  '/js/todo-list.js#TodoList',
  TodoList,
)

export function handleRequest(request: Request): Response {
  let stream = renderToStream(
    <Layout title="Todos">
      <InteractiveTodoList />
    </Layout>,
    {
      frameSrc: request.url,
      onError: (error) => console.error(error),
    },
  )

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

What is clientEntry?

clientEntry marks a component for selective hydration. The server renders it as HTML with a marker. When run() starts on the client, it loads the module and hydrates only that component. This keeps the JavaScript payload small --- only interactive components ship code to the browser.

What You Learned

  • Double-function pattern: The outer function sets up; the inner function renders.
  • State: Use plain variables and call handle.update() to re-render.
  • Mixins: css() for styles, on() for events, ref() for DOM access. Combine them in arrays.
  • Server rendering: renderToString for complete HTML, renderToStream for progressive rendering.
  • Frames: Frame components load server content into page regions.
  • Hydration: run() attaches interactivity to server-rendered HTML. createRoot for client-only rendering.
  • Client entries: clientEntry marks components for selective hydration.

Released under the MIT License.