Notes on Remix 3

← All notes

The Remix 3 component model is not React without hooks — it is a different question being asked, where state lives in setup scope, re-renders are something you ask for on purpose, and the closest reference point is the early days of Knockout, not Preact

by A Developer ·  May 24, 2026  ·  6 min read

Components return a render function. You read props from a handle. You call `handle.update()` when something changes. Three sentences in, you are going to want to know what the trade-off is — so let’s talk about that.

The phrase that comes out of every React engineer’s mouth on their first afternoon in Remix 3 is: wait, where do I put the state?

It’s a fair question. In React, state lives in a hook. The hook lives inside the component function body. The component function body runs on every render. The whole mental model is shaped around that loop. Lose the loop and the question stops having an obvious answer.

Here is a Remix 3 component. It’s a counter. It does what you think it does:

import { on, type Handle } from 'remix/ui'

function Counter(handle: Handle<{ label: string }>) {
  let count = 0

  return () => (
    <button
      mix={on('click', () => {
        count++
        handle.update()
      })}
    >
      {handle.props.label}: {count}
    </button>
  )
}

Three things in there are doing all the work:

1. The outer function runs once

Counter is called the first time the component mounts. That’s it. Not on every render. Not in development on a double-render to teach you about purity. Once. Anything you put at the top of the function — variables, refs, subscriptions, listeners — is set up one time.

That’s where the state lives. let count = 0 isn’t a magic primitive. It’s a regular JavaScript variable in a regular closure.

2. The render function runs many times

The thing the outer function returns is the render function. Remix calls it every time the component needs to repaint. It closes over the outer scope, so it sees the latest value of count without anything having to be passed in.

Notice what isn’t in there: no useState, no destructuring of a tuple, no dependency array, no useMemo to stop a child from re-rendering. The render function is just the JSX it returns.

3. Updates are explicit

This is the part that takes a beat. In React, you call setCount(count + 1) and the framework decides when to re-render. In Remix 3, you mutate count and call handle.update() to tell the framework you’d like to repaint.

Why? Because mutation is cheap and the framework has no way to observe it. The handle gives Remix a place to subscribe to your intent: I changed something, please run my render function again. No proxies, no signals graph, no compiler magic. Just a method call.

The mental shift is small but real. You stop describing state transitions to a framework and start asking the framework to look at the state when you’re done changing it.

Props live on the handle

The other half of the model: props don’t come in as a function argument. They live on handle.props. The reason is symmetric with the render function — props can change between renders, and the outer function doesn’t run again to receive them. The render function reads handle.props fresh each time.

The two patterns it changes:

  1. Destructuring moves inside. You write const { label } = handle.props inside the render function, not at the top.
  2. Effects that depend on props go in event handlers or queueTask. There’s nouseEffect equivalent watching for prop changes — you do the work when the change happens, not in response to a render.

What you give up

Three real costs, named honestly:

The ecosystem. Every React library that uses hooks internally — including the ones you don’t realize use hooks — does not work here. The fix is usually to use the Remix primitive instead, but the catalog is smaller and younger.

The familiarity. A team that has only ever written React will write its first Remix 3 component slightly wrong. They will put state inside the render function. They will forget to call handle.update(). They will reach for hooks. The first week is bumpy.

The implicit re-render. React’s model has a real upside: when a parent re-renders, children re-render, and you mostly don’t have to think about it. Remix asks you to think about it. That is good for performance and bad for muscle memory.

What you get back

The rules fit on a Post-it:

  • The outer function runs once. Put setup there.
  • The inner function runs many times. Put JSX there.
  • State is just variables. Mutate them.
  • When you’re done mutating, call handle.update().
  • Read props from handle.props, not from arguments.

Five rules. No effects to time, no dependencies to track, no re-renders to memoize away. A component is a small object with one method, and you’re writing the method.

Is it better?

Different question. It is smaller. The runtime that ships to the browser is a fraction of React’s. The mental model is fewer pieces. The set of things you have to know to read someone else’s component is shorter.

Whether that’s an improvement is a taste question. If your bug reports usually start with “the component re-rendered when it shouldn’t have” — Remix 3 will feel like a release. If your bug reports usually start with “the component didn’t re-render when it should have” — you will hate it for a week and then you’ll be fine.

Either way, it’s the first new component model in a while that actually is new, instead of being React with a different bundler.