Component Tutorial
In this tutorial you will build three things:
- An interactive counter --- learn the double-function pattern, state, and event handling.
- A todo list --- learn the
cssmixin, lists, and conditional rendering. - A streaming page --- learn server rendering, client hydration, and the
Framecomponent.
Prerequisites
Set up your tsconfig.json so TypeScript knows to use the Remix JSX runtime:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "remix/component"
}
}All imports in this tutorial come from the remix meta-package:
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.
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:
<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.
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.
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.
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:
interface Todo {
id: number
text: string
done: boolean
}Step 2 --- The TodoList Component
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.
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.
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:
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.
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.
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:
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.
// 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:
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:
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:
renderToStringfor complete HTML,renderToStreamfor progressive rendering. - Frames:
Framecomponents load server content into page regions. - Hydration:
run()attaches interactivity to server-rendered HTML.createRootfor client-only rendering. - Client entries:
clientEntrymarks components for selective hydration.
Related
- Component Overview --- Feature summary and how it differs from React.
- Component Reference --- Full API reference.
- Streaming --- Deep dive into streaming server rendering.