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.
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:
$
hook.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>
)
}
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.
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.
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:
NPM Package: