Skip to main content

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')
Naming atoms

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')
})