Unpacking Remix 3 Pt.1: Frontend
Ryan Florence's walkthrough of Remix 3's component model and event system
Introduction: Rethinking State and Events
Ryan Florence begins by examining a common pattern in web development: managing state and event listeners together. Looking at a tempo tapper application, he identifies two event listeners, a handle tap function, and various pieces of state. Some state clearly belongs to the UI (like the tempo display), while other pieces feel more ephemeral (like recent taps and timers).
The question emerges: in a React component, all this state might live together in component state. But is there a better way to separate event-related state from UI state?
Remix Events: A Higher-Level Language for the Web
Events are fundamental to the web platform. Everything is an event target: DOM nodes, window, document, WebSockets, XHR, and even in Node.js and modern JavaScript runtimes. Remix 3 introduces remix-events
, a package that provides a higher-level language for working with events.
Instead of raw addEventListener
calls with magic strings, Remix lets you reference events as first-class typed objects. The package ships with an events
API and composed events like press
, pressDown
, longPress
, and outerPress
that encapsulate multiple lower-level events and internal state.
Custom Interactions: Encapsulating Event State
Remix events allow you to create custom interactions similar to how components encapsulate elements. Using createInteraction
, you can bundle event handlers, state management, and type-safe custom events into reusable units.
Creating a custom tempo interaction that encapsulates state and dispatches type-safe events
The tempo tapper becomes a custom interaction that:
- Manages internal state (recent taps, timers, intervals)
- Handles the press events
- Dispatches a type-safe custom event with the calculated tempo
- Can be imported and reused anywhere
Instead of setting state directly, interactions dispatch events. Anyone listening can respond accordingly. This creates a clean separation: event-related state lives in interactions, not cluttering up components.
The Component Model: Setup Scope and Render Functions
Remix 3 components work differently from React. A component is a function that returns a render function. The outer function runs once, creating a "setup scope" where you can initialize state, set up event listeners, and configure your component. This scope persists in JavaScript memory.
A Remix component showing the setup scope with local state and explicit this.update() calls
State is just regular JavaScript variables in this closure. When you want to update the UI, you explicitly call this.update()
. The button doesn't automatically know when bpm
changes - and that's the point. You control exactly when renders happen.
This isn't a feature of Remix. It's just how JavaScript works. The setup scope is called once when your component is being rendered. All the state gets captured into JavaScript memory, and when you say this.update()
later, Remix grabs the render function you handed it and calls it again. All of your state is yours to manage however you want.
Context: Type-Safe and Explicit
Remix 3's context API offers a fresh approach. Context is typed using a generic on the component handle. To provide context, you use this.context.set()
. Setting context doesn't cause a render - it's just storing a value internally.
Consuming context with this.context.get(App) - type-safe and navigable via "go to definition"
To consume context, you reference the component that provides it using this.context.get(App)
. This is type-safe and navigable - go to definition takes you straight to the provider. No searching for context providers across your codebase.
Components subscribe to context changes through event listeners on the context object itself. Since the drummer is an event target, you can listen to its changes with standard addEventListener patterns.
Manual Fine-Grained Reactivity
Ryan describes Remix 3's model as "MFGR" (Manual Fine-Grained Reactivity). You get fine-grained reactivity by setting it up yourself. Nothing updates unless you explicitly call this.update()
.
When debugging, if a component is updating unexpectedly, the answer is simple: you told it to somewhere. No accidental loops, no mysterious reactivity chains. If you want a loop, you'll have to write a recursive function that calls update - and you'll see that you did it to yourself.
This explicitness trades some automation for complete clarity about what's happening and when. While systems that automatically track dependencies and update accordingly seem appealing, Ryan's experience shows that setting them up is difficult, and debugging unexpected updates becomes a maze of trying to unravel reactive chains.
Async Event Handlers and Race Conditions
Remix 3 has a principle: anytime you give us a function to call, we give you a signal. Event handlers receive a signal as their second argument. This enables elegant handling of async operations and race conditions.
An async onChange handler receiving a signal parameter to handle race conditions elegantly
When building a dependent select box (choosing a state, then loading cities for that state), the naive implementation has race conditions. If you select Kentucky, then Illinois, then Arizona quickly, the responses might arrive out of order, leaving you with Louisville shown when Arizona is selected.
The solution is simple: pass the signal to fetch. When the function is re-entered (user makes another selection), the previous signal aborts. The fetch API respects abort signals, automatically cancelling stale requests. No complex state machines needed - just check the signal after async operations.
This principle extends everywhere: component lifecycle signals abort when components unmount, event callback signals abort when the function is re-entered or the component disconnects, and render signals abort when re-rendered.
Signals: Lifecycle and Cleanup
Every component gets a signal on this.signal
that aborts when the component unmounts. This is a standard AbortController signal, just like on the web platform. You can use it for cleanup by listening to the abort event, or pass it to async operations.
Queue Task: Post-Render Callbacks
For focus management and other DOM operations that need to happen after rendering, Remix provides this.queueTask()
. It queues a function to run after the DOM updates, solving the classic problem of trying to focus a disabled element before it's enabled.
Remix doesn't do mega-batching of rendering, but it does batch everything into one microtask to deduplicate renders. If a parent and child both listen to window scroll and both say update, Remix deduplicates and only renders the parent.
Semantic Keyboard Events
The event system extends to keyboard interactions with semantic events. Instead of checking event.key === ' '
, you use named events like space
, arrowUp
, arrowDown
. You can bind these to window or any element using the same on
prop pattern.
These events compose - calling event.preventDefault()
in one handler prevents subsequent handlers in the same array from running, making it easy to build component libraries where parent and child components need to coordinate event handling.
Component Library
Remix 3 will ship with a comprehensive component library built on these primitives. The library includes complex components like nested dropdown menus with hover intent, form controls like accessible list boxes built from divs but behaving exactly like native selects, and layout components using a theme system based on CSS custom properties.
The custom list box demonstrates the power of the event system. It dispatches proper change events that bubble up through the DOM, so you can handle listbox changes at the form level rather than on each individual component. Events bubble just like native DOM events.
Web Components Integration
Because Remix 3 uses standard custom events, components work seamlessly with web components. You can wrap Remix components in custom elements and they remain fully functional, with events bubbling through the shadow DOM boundary. This makes Remix components embeddable in any context - even environments you don't control.
Conclusion: Simplicity Through Explicitness
Remix 3 returns to fundamentals: JavaScript closures, standard DOM events, and explicit control flow. There are no special types required, no hidden runtimes, no magic. If something happens, your code said it should happen.
The entire Remix API fits on one hand: context
, update
, signal
, queueTask
, raise
, and a few others. The event system in Remix is just DOM events but typed.
After years of chasing automated reactivity and complex dependency graphs, Remix 3 offers something different: the simplicity of explicit updates with the power of fine-grained control. As Ryan puts it, "Let me be" - just let developers write straightforward code without fighting the framework eg. use a simple let
for state variables.
The philosophy is clear: climb down from the abstraction mountain. Don't bring in abstractions until you absolutely need them. Start with simple variables, regular functions, and explicit control. Build up only when necessary.
This demonstration represents three months of intensive work on Remix 3, showing a paradigm shift in how we think about components, state, and events on the web platform.