Reactive UI without dark magic.
- Fine-grained reactive signals with automatic dependency tracking
- Vanilla JavaScript with types provided by JSDoc
- Zero dependencies, no build step
- Less than 4kb compressed
Signals are reactive state containers that update whenever their values change.
signal
takes an intial value, and returns a getter and a setter. The getter is a zero-argument function that returns the current value, and the setter can be called to set a new value for the signal. This will feel familiar if you've ever used React hooks.
import {signal} from './spellcaster.js'
const [count, setCount] = signal(0)
console.log(count()) // 0
// Update the signal
setCount(1)
console.log(count()) // 1
So far, so good. But signals have a hidden superpower: they're reactive!
When we reference a signal within a rective scope, that scope will re-run whenever the signal value updates. For example, let's create a derived signal from another signal, using computed()
.
import {signal, computed} from './spellcaster.js'
const [todos, setTodos] = signal([
{ text: 'Chop wood', isComplete: true },
{ text: 'Carry water', isComplete: false }
])
// Create a computed signal from other signals
const completed = computed(() => {
// Re-runs automatically when todos signal changes
return todos().filter(todo => todo.isComplete).length
})
console.log(completed()) // 1
computed
runs the function you provide within a reactive scope, so when the signal changes, the function is re-run.
What about when you want to react to value changes? That's where effect
comes in. It lets you perform a side-effect whenever a signal changes:
// Log every time title changes
effect(() => console.log(title()))
Effect is where signals meet the real world. You can use effect
like you might use useEffect
in React... to kick off HTTP requests, perform DOM mutations, or anything else that should react to state updates.
Spellcaster is a vanilla JavaScript module. You can import it directly. No build step needed.
import * as spellcaster from './spellcaster.js'
Here's a simple counter example using signals and hyperscript.
import {signal} from './spellcaster.js'
import {h, text, children} from './hyperscript.js'
const viewCounter = () => {
const [count, setCount] = signal(0)
return h(
'div',
{className: 'wrapper'},
children(
h(
'div',
{className: 'count'},
text(count)
),
h(
'button',
{onclick: () => setCount(count() + 1)},
text('Increment')
)
)
)
}
const view = viewCounter()
document.body.append(view)
What's going on here? To make sense of this, let's rewrite this component using only signals and vanilla DOM methods.
const viewCounter = () => {
const [count, setCount] = signal(0)
const wrapper = document.createElement('div')
wrapper.className = 'wrapper'
// Create count element
const count = document.createElement('div')
count.className = 'count'
wrapper.append(count)
// Create button
const button = document.createElement('button')
button.textContent = 'Increment'
// Set count when button is clicked
button.onclick = () => setCount(count() + 1)
wrapper.append(button)
// Write text whenever signal changes
effect(() => count.textContent = text())
return wrapper
}
We can see that hyperscript is just an ergonomic way to build elements. We're just constructing and returning ordinary DOM elements here! Since signals are reactive representations of values, the returned element is reactive. When the signal value changes, the element automatically updates, making precision changes to the DOM. No virtual DOM diffing is needed!
The above example uses signal
for local component state, but you can also pass a signal down from a parent.
const viewTitle = title => h('h1', {className: 'title'}, text(title))
Here's a more complex example, with some dynamic properties. Instead of passing h()
a props object, we'll pass it a function that returns an object. This function is evaluated within a reactive scope, so whenever isHidden()
changes, the props are updated.
const viewModal = (isHidden, ...childViews) => h(
'div',
() => ({
className: 'modal',
hidden: isHidden()
}),
children(...childViews)
)
Passing down signals allows you to share reactive state between components. You can even centralize all of your application state into one signal, and pass down scoped signals to sub-components using computed
.
Signals give you ergonomic, efficient, reactive components, without a virtual DOM or compile step.
computed()
lets you to derive a signal from one or more other signals.
import {signal, computed} from './spellcaster.js'
const [todos, setTodos] = signal([
{ text: 'Chop wood', isComplete: true },
{ text: 'Carry water', isComplete: false }
])
// Create a computed signal from other signals
const completed = computed(() => {
// Re-runs automatically when todos signal changes
return todos().filter(todo => todo.isComplete).length
})
console.log(completed()) // 1
Computed signals automatically track their dependencies, and recompute whenever their dependencies change. Only the signals that are executed are registered as dependencies. For example, if you have an if
statement and each arm references different signals, only the signals in the active arm will be registered as dependencies. If a signal stops being executed (for example, if it is in the inactive arm of an if statement), it will be automatically de-registered.
const fizzBuzz = computed(() => {
if (isActive()) {
// Registered as a dependency only when isActive is true
return fizz()
} else {
// Registered as a dependency only when isActive is false
return buzz()
}
})
You never have to worry about registering and removing listeners, or cancelling subscriptions. Spellcaster manages all of that for you. We call this fine-grained reactivity.
Simple apps that use local component state may not need computed
, but it comes in handy for complex apps that want to centralize state in one place.
store
offers an Elm/Redux-like store for managing application state.
- All application state can be centralized in a single store.
- State is only be updated via a reducer function, making state changes predictable and reproducible.
- Store manages asynchronous side-effects with an effects runner.
store
can be initialized and used much like signal
. However, instead of being initialized with a value, it is initialized with two functions: init()
and update(state, msg)
. Both functions return a transaction object (created with next
) that contains the next state. Store returns a signal for the state, as well as a send function that allows you to send messages to the store.
const init = () => next({
count: 0
})
const update = (state, msg) => {
switch (msg.type) {
case 'increment':
return next({...state, count: state.count + 1})
default:
return next(state)
}
}
const [state, send] = store({init, update})
console.log(state()) // {count: 0}
send({type: 'increment'})
console.log(state()) // {count: 1}
Transactions can also include asynchronous side-effects, such as HTTP requests and timers. Effects are modeled as zero-argument functions that return a message, or a promise for a message.
// Fetches count from API and returns it as a message
const fetchCount = async () => {
const resp = await fetch('https://api.example.com/count').json()
const count = resp.count
return {type: 'setCount', count}
}
const update = (state, msg) => {
switch (msg.type) {
case 'fetchCount':
// Include effects with transaction
return next(state, [fetchCount])
case 'setCount':
return next({...state, count: msg.count})
default:
return next(state)
}
}
Store will perform each effect concurrently, and feed their resulting messages back into the store. This allows you to model side-effects along with state changes in your reducer function, making side-effects deterministic and predictable.
Spellcaster hyperscript is a functional shorthand for creating reactive HTML elements.
h(tag, props, config)
- Parameters
tag
- a string for the tag to be createdprops:
- an object, or a signal for an object that contains props to be set on the elementconfig(element)?
- an optional callback that receives the constructed element and can modify it
- Returns:
HTMLElement
- the constructed element
h()
can be used with config helpers like text()
and children()
to efficiently build HTML elements.
const viewTitle = title => h(
'h1',
{className: 'title'},
// Set text content of element
text(title)
)
const viewModal = (isHidden, ...content) => h(
'div',
() => ({
className: 'modal',
hidden: isHidden()
}),
// Assign a static list of children to element
children(...content)
)
const viewCustom = () => h(
'div',
{},
element => {
// Custom logic
}
)
What about rendering dynamic lists of children? For this, we can use repeat()
. It takes a () => Map<Key, Item>
and will efficiently re-render children, updating, moving, or removing elements as needed, making the minimal number of DOM modifications.
const viewTodos = todos => h(
'div',
{className: 'todos'},
repeat(todos, viewTodo)
)
With hyperscript, most of the DOM tree is static. Only dynamic properties, text, and repeat()
are dynamic. This design approach is inspired by SwiftUI, and it makes DOM updates extremely efficient.