Using Svelte Runes in React

As a Vue developer, learning React initially felt like a step backward. The boilerplate, the dependency arrays, the constant battle with the compiler—it all seemed unnecessarily complex. Vue’s intuitive reactivity system just clicked with my mental model of how code should work. And when I discovered Svelte, with its elegant runes system, I found something even better.

But here’s the thing: React isn’t going anywhere. It’s become the de facto standard for frontend development, backed by Meta and boasting the largest ecosystem of tools, libraries, and job opportunities. Theo explains this dynamic well in his video:

While I’ve grown more comfortable with React’s patterns, I still find myself longing for Svelte’s simplicity. There’s something beautiful about how Svelte lets you write exactly what you mean, while React often feels like you’re negotiating with the framework about your intentions.

So I did what any stubborn developer would do: instead of switching frameworks, I decided to bring Svelte’s elegance to React. This is that experiment.

Svelte 5-Inspired Reactivity for Next.js Apps#

Traditional React state management often involves hooks like useState and useEffect, leading to explicit dependency arrays and sometimes verbose code. While powerful, this can obscure the flow of reactivity and lead to subtle bugs if dependencies are missed.

React Runes aims to simplify this by introducing a “runes” system—familiar to Svelte developers—that provides:

  • Simplicity: A minimal API surface that’s easy to grasp.
  • Ergonomics: Reactive values in your components with a single, intuitive $ hook.
  • Performance: Built on fine-grained reactivity principles, reducing unnecessary re-renders.
  • Clarity: Makes it explicit which values in your components are reactive.

How It Works: The Core Runes#

React Runes provides three fundamental primitives: state, derived, and effect, along with a powerful $ hook.

  • state(initial): Creates a reactive state variable. When its value changes, anything depending on it automatically updates.
  • derived(fn): Defines a computed value. This function automatically re-runs whenever any of its dependencies (other runes) change, and its value is then updated.
  • effect(fn): Executes a side effect. The effect function runs whenever its dependencies change, and it can optionally return a cleanup function.

And then, there’s the star of the show for React components:

  • $(rune) (The $ Hook): This Svelte-inspired hook is how you consume reactive runes directly within your React components. When you wrap a state or derived rune with $(...), your component automatically subscribes to its value and re-renders only when that specific rune’s value changes.

Here’s a quick example:

import { state, derived, effect, $ } from 'react-runes'

const count = state(0)
const doubleCount = derived(() => count.value * 2)

effect(() => {
  console.log('Count changed:', count.value)
})

export default function Counter() {
  const countValue = $(count)
  const doubleValue = $(doubleCount)

  return (
    <div>
      <p>Count: {countValue}</p>
      <p>Double: {doubleValue}</p>
      <button onClick={() => (count.value += 1)}>Increment</button>
    </div>
  )
}

Notice how count.value and doubleCount.value are directly accessed within the derived and effect, and how $(count) and $(doubleCount) keep the Counter component reactive without explicit useState or useEffect calls for subscription.

Interactive Example

Two-way Input Binding

import { state, $ } from 'react-runes'

const name = state('')

export default function NameInput() {
  const value = $(name)
  return (
    <input
      value={value}
      onChange={e => (name.value = e.target.value)}
      placeholder="Enter your name"
    />
  )
}

Form with Validation

import { state, derived, $ } from 'react-runes'

const email = state('')
const error = derived(() =>
  email.value.includes('@') ? '' : 'Email must include @'
)

export default function EmailForm() {
  const emailValue = $(email)
  const errorMsg = $(error)
  return (
    <form>
      <input
        value={emailValue}
        onChange={e => (email.value = e.target.value)}
        placeholder="Email"
      />
      {errorMsg && <span style={{ color: 'red' }}>{errorMsg}</span>}
      <button type="submit" disabled={!!errorMsg}>Submit</button>
    </form>
  )
}

Async State Example

import { state, effect, $ } from 'react-runes'

const data = state(null)
const loading = state(false)

function fetchData() {
  loading.value = true
  fetch('/api/data')
    .then(res => res.json())
    .then(json => (data.value = json))
    .finally(() => (loading.value = false))
}

effect(() => {
  fetchData()
})

export default function DataDisplay() {
  const isLoading = $(loading)
  const result = $(data)
  return (
    <div>
      {isLoading ? 'Loading...' : JSON.stringify(result)}
    </div>
  )
}

Under the Hood#

React Runes leverages zustand for its global state management. This allows for an efficient, centralized system to track all runes, manage their dependencies, and batch updates for optimal performance. When a state rune is updated, the system intelligently identifies and re-runs only the derived and effect runes that depend on it, propagating changes efficiently.

Vertical Slice Architecture#

One of the philosophies behind React Runes is to encourage a “vertical slice” architecture. This means co-locating your component’s UI, its reactive state (runes), and related logic within the same directory. This enhances discoverability and makes features more self-contained.

Limitations and Future#

It’s important to reiterate that React Runes is an experiment and not intended for production use. It pushes the boundaries of React ergonomics and explores alternative reactivity patterns. As such, some React tooling (like linting or auto-imports) may not fully recognize $ as a conventional hook.

This project is a playground for reactivity ideas within the React ecosystem. I invite you to explore the codebase, try it out, and share your feedback. Contributions, ideas, and discussions are always welcome.


Source Code:

trentbrew
/
react-runes
Waiting for api.github.com...
00K
0K
0K
Waiting...

NPM Package:

react-runes
Loading from registry.npmjs.org...