Using Signals
There are two types of signal:
- Atoms which store the root state for your app
- Computed signals which are derived from other signals
Atoms
Creating Atoms
Atoms can be created using the atom
function, which takes a name
for the atom, and an initialValue
.
import { atom } from 'signia'
const firstName = atom('firstName', 'David')
We make the name
of the atom a required first argument to aid debugging. Typing the name twice is a small price to pay for a happier debugging experience 🙂
The name does not need to be unique since it is not used except for debugging and potentially by dev tooling.
If you have an idea for how to use build tooling to infer the name automatically we'd gladly support you in implementing it!
Updating Atoms
Atoms can be updated using the set
method.
firstName.set('John')
console.log(firstName.value) // John
Or by using the update
method, which takes a function that receives the current value and returns the new value.
firstName.update((value) => value.toUpperCase())
console.log(firstName.value) // JOHN
Computed Signals
Creating Computed Signals
Computed signals can be created using the computed
function, which takes a name
for the signal, and a compute
function.
import { computed, atom } from 'signia'
const firstName = atom('firstName', 'David')
const lastName = atom('lastName', 'Bowie')
const fullName = computed('fullName', () => {
return `${firstName.value} ${lastName.value}`
})
console.log(fullName.value) // David Bowie
Updating Computed Signals
Computed signals are read-only, and cannot be updated directly. Instead, update their root atoms and the computed signal will update automatically.
firstName.set('John')
console.log(fullName.value) // John Bowie
Using Classes to Organize Code
For real applications, we recommend using a class as a way to group atoms with related code such as:
- functions that perform mutations
- shared derived data
- lifecycle management code
Classes also make it easy to make atoms 'private' which helps to keep code tidy by preventing mutation code from being spread across the codebase.
In this tutorial we will create a TextDocument
class for managing a text document in a hypothetical rich text editor.
import { atom } from 'signia'
type TextDocumentState = {
title: string
text: string
cursorPosition: number
selectionRange: null | [number, number]
}
class TextDocument {
private readonly _state = atom<TextDocumentState>('TextDocument._state', {
title: 'My Document',
text: 'Lorem ipsum dolor sit amet...',
cursorPosition: 0,
selectionRange: null,
})
get state() {
return this._state.value
}
setCursor(position: number) {
this._state.update((state) => {
return {
...state,
cursorPosition: position,
}
})
}
setSelectionRange(range: [number, number]) {
this._state.update((state) => {
return {
...state,
selectionRange: range,
}
})
}
}
The @computed
decorator
The @computed
decorator can be used within a class to create a 'transparent' computed signal with which you don't need to use .value
to get the value.
import { computed } from 'signia'
class TextDocument {
// ...
@computed get title() {
return this.state.title
}
@computed get wordCount() {
return this.state.text.split(' ').length
}
@computed get selectedText() {
if (this.state.selectionRange === null) {
return ''
}
const [start, end] = this._state.selectionRange
return this.state.text.slice(start, end)
}
}
new TextDocument().wordCount // 5
If you need to access the underlying Computed
instance, you can use getComputedInstance
import { getComputedInstance } from 'signia'
const doc = new TextDocument()
const wordCount = getComputedInstance(doc, 'wordCount')
console.log(wordCount.value) // 5
Running effects
If you want to run some code whenever a signal changes, you can use the react
function.
import { react } from 'signia'
const doc = new TextDocument()
// `react` returns a function that can be called to stop the effect
const stop = react('set page title', () => {
document.title = doc.title
})
If you need something that can be started and stopped multiple times, you can use the reactor
function.
import { reactor } from 'signia'
const effect = reactor('set page title', () => {
document.title = doc.title
})
effect.start()
effect.stop()
reactor
and react
also support providing a scheduler
option which can be used to defer effects until another time. A common use case for this would be to batch effects to run on a single animation frame.
let isRafScheduled = false
const scheduledEffects: Array<() => void> = []
const scheduleEffect = (runEffect: () => void) => {
scheduledEffects.push(runEffect)
if (!isRafScheduled) {
isRafScheduled = true
requestAnimationFrame(() => {
isRafScheduled = false
scheduledEffects.forEach((runEffect) => runEffect())
scheduledEffects.length = 0
})
}
}
const stop = react('set page title', () => {
document.title = doc.title,
}, scheduleEffect)
Transactions
If you need to update multiple atoms at once, you can use the transact
function to defer running side effects until after all updates complete.
import { transact } from 'signia'
const firstName = atom('firstName', 'David')
const lastName = atom('lastName', 'Bowie')
const fullName = computed('fullName', () => {
return `${firstName.value} ${lastName.value}`
})
transact(() => {
firstName.set('John')
lastName.set('Lennon')
})
transact
has no effect if used inside an already-running transaction.
Rollbacks
If an error is thrown by the root transact
, all updates made within the scope will be rolled back.
If you need to be able to rollback a nested set of updates within a transaction, you can use the transaction
function to explicitly create a new transaction boundary.
transact(() => {
firstName.set('John')
lastName.set('Lennon')
transaction((rollback) => {
firstName.set('Paul')
lastName.set('McCartney')
console.log(fullName.value) // Paul McCartney
rollback()
})
console.log(fullName.value) // John Lennon
firstName.set('George')
lastName.set('Harrison')
})