0.3.2 ā€¢ Published 9 months ago

signalit v0.3.2

Weekly downloads
-
License
MIT
Repository
github
Last release
9 months ago

šŸ“» SignalIt

Simple and performant reactive primitive for React

Open library on CodeSandbox

Open demo on CodeSandbox (Open Chrome Devtools to see debugging DX)

āš ļø This tool is not ready for production as Prettier, Linters etc. needs to support the incoming using keyword for JavaScript/TypeScript. This should be available by end of August 2023

šŸ—„ļø Allows you to organise observable state and related logic outside the component tree

šŸš€ Increases performance as components only reconciles based on what it observes

šŸ” Does not use proxies to achieve reactiveness through mutation. It rather relies on simple getter/setter and treats its value as immutable, just like React expects

šŸ› Uses explicit resource management to observe signals in components, which eliminates overhead to the component tree and improves the debugging experience

šŸ• Async signals with suspense

šŸ’» Lazily compute signals

:accessibility: Allows debugging and exploring signals at runtime with source mapped references to the code observing and changing signals. This allows you and fellow developers understand what CODE drives your state changes, not just abstract action names

Table Of Contents

Getting Started

npm install signalit

TypeScript 5.2 (Currently in Beta)

@babel/plugin-proposal-explicit-resource-management

Example

import { signal, observe } from 'signalit'

// Create a signal wherever
const count = signal(0)

const SomeComponent = () => {
    // Use the new "using" keyword to observe the component scope for signal access
    using _ = observe()
    
    // Access the value using "signal.value", which is also how you change it
    return (
        <div>
            <h4>The count is ${count.value}</h4>
            <button onClick={() => {
                count.value++
            }}>Increase</button>
        </div>
    )
}

API

Signal

The signal instance.

Created with factory signal<T>(initialValue: T): Signal<T>:

import { signal } from 'signalit'

const count = signal(0)

Signal.value

Access and change the value.

import { signal } from 'signalit'

const count = signal(0)

// Access the current value
count.value

// Change the current value
count.value += 1

Signal.onChange

Subscribe to changes on the signal with Signal<T>.onChange(listener: (value: T, prevValue: T) => void): () => void.

import { signal } from 'signalit'

const count = signal(0)

const dispose = count.onChange((newCount, prevCount) => {
  
})

AsyncSignal

An async signal instance which enhances the promise with suspense support.

import { asyncSignal } from 'signalit'

// fetchSomeData returns a native Promise
const data = asyncSignal(fetchSomeData())

// AsyncSignal converts it to a CachedPromise
data.value

AsyncSignal.value

Access and change the value of the promise.

import { asyncSignal } from 'signalit'

const data = asyncSignal(fetchSomeData())

// Immediately changes the promise, but will only notify observers when promise is resolved/rejected
data.value = fetchSomeOtherData()

// Just updating a signal promise with a new value keeps it as a promise and notifies observers 
// as the value is being resolved
data.value = {}

AsyncSignal.value.use()

A hook which allows synchronous access to resolved values, throw to suspense when pending or throw to error boundary when rejected.

Note! This hook is likely to become a native hook in React in the near future.

import { asyncSignal, observe } from 'signalit'

const dataPromise = asyncSignal(fetchSomeData())

const SomeComponent = () => {
    using _ = observe()

    // Will throw to suspense/error when pending/rejected, or synchronously access the promise
    // if it is already resolved
    const data = dataPromise.value.use()

    // When native hook available
    const data = use(dataPromise.value)
    
    return (
        <div>
            <h4>The data is ${data}</h4>
        </div>
    )
}

AsyncSignal.onChange

Subscribe to changes on the signal with AsyncSignal<T>.onChange(listener: (value: T, prevValue: T) => void): () => void. This only triggers when the new value has actually been resolved, not when the promise is replaced.

import { asyncSignal } from 'signalit'

const count = asyncSignal(Promise.resolve(0))

const dispose = count.onChange((newCount, prevCount) => {
  
})

compute()

Created with compute<T>(computeFn: () => T): Signal<T>. Creates a signal that lazily recomputes whenever any accessed signals within the compute callback changes.

import { compute, signal } from 'signalit'

const count = signal(0)
const shoutingCount = compute(() => count.value + '!!!')

observe()

Creates an observation context which stops observing when the component scope exits.

import { signal, observe } from 'signalit'

const count = signal(0)

const SomeComponent = () => {
    // Creates the observation context
    using _ = observe()
    
    return (
        <div>
            <h4>The count is ${count.value}</h4>
        </div>
    )
    // Stops observing and subscribes to any signals observed
}

useSignal()

Create using useSignal<T>(initialValue: T): Signal<T>. Local component state which is useful to embrace signals for state management. Also improves debugging experience.

import { observe, useSignal } from 'signalit'

const SomeComponent = () => {
    using _ = observe()

    const count = useSignal(0)
    
    return (
        <div>
            <h4>The count is ${count.value}</h4>
            <button onClick={() => {
                count.value++
            }}>Increase</Button>
        </div>
    )
}

Design Decisions

It was decided that SignalIt should be as simple as possible, with no magic and adhere to the principles of React. This is why there are no proxies, but rather a setter which creates the reactive mechanism. Even though you might define your signals in a mutable context, like classes, you will still change them with immutable principles. In other words you will always replace the signal value, never change nested objects or use other mutable APIs like array push etc.

To understand why SignalIt approach observability with the using keyword you have to understand the tradeoffs being made with existing approaches.

The most straight forward way to observe changes is with a higher order component:

import { signal, observer } from 'signalit'

const count = signal(0)

const SomeComponent = observer(() => {
    return (
        <div>
            {count.value}
        </div>
    )
})

Here we create a higher order component which encapsulates the observing in the component function body. From a syntax and mental model perspective this makes a lot of sense, but it has several drawbacks.

It fills up your component tree with wrapper components named Observer where your previously named components becomes an Anonymous child in the React Developer tools. Having these wrapper components and lack of named components it not ideal for debugging purposes.

Passing refs will now require you to do a forwardRef as we are passing the ref through a parent component. It is not often you do this, but it is not ideal.

We could use an Observer component which creates its own isolated scope for tracking signals as well:

import { signal, Observer } from 'signalit'

const count = signal(0)

const SomeComponent = () => {
    return (
        <Observer>
            {() => (
                <div>
                    {count.value}
                </div>
            )}
        </Observer>
    )
}

The drawback of the Observer component is that it only works in JSX, meaning any tracking of signals used related to hooks will not be tracked.

We could also go for a useSignals hook which would prevent the drawbacks of previous approachs:

import { signal, useSignals } from 'signalit'

const count = signal(0)

const SomeComponent = () => {
    return useSignals(() => (
        <div>
            {count.value}
        </div>
    ))
}

But the useSignals hook has subtle, but important drawbacks to highlight. For example when you want to track signals related to an other hook:

import { signal, observer } from 'signalit'

const count = signal(0)

const SomeComponent = () => {
    return useSignals(() => {
        useEffect(() => {
          console.log("Count has changed", count.value)    
        }, [count.value])
        
        return (
            <div>
                {count.value}
            </div>
        )
    })
}

Even though technically signalit can guarantee that the callback of useSignals always runs, also ensuring that all hooks expressed inside runs, any static code analysis can not. Just like how new Promise(() => {}) runs synchonously, but TypeScript will yell if you try to access an optional property already verified in the outer scope. This can cause issues with linting and also typescript evaluating values in the outside component scope.

Additionally it creates quite of an exotic API that could be misused. For example users might start doing:

import { signal, observer } from 'signalit'

const count = signal(0)

const SomeComponent = () => {
    const signals = useSignals(() => ({ count: count.value }))
}

Which really goes against the concept of signals as you are now explicitly subscribing to signals as opposed to let the nature of just accessing a signal in a component creating the subscription.

With explicit resource management, or the using keyword, we are able to resolve all the before mentioned issues.

import { signal, observe } from 'signalit'

const count = signal(0)

const SomeComponent = () => {
    using _ = observe()
    
    useEffect(() => {
        console.log("Count has changed", count.value)    
    }, [count.value])
    
    return (
        <div>
            {count.value}
        </div>
    )
}

We specifically avoid:

  • Flooding our component tree with wrapper components
  • Components without a name
  • Forward refs
  • Having multiple ways to express observation
  • Risking wrong usage of API
  • Linter issues

You can certainly argue that using a new API of the language is not ideal, especially when it means using a new keyword like using. I would expect a similar pushback like async/await, but this is the right tool for the job.

0.3.2

9 months ago

0.3.1

10 months ago

0.3.0

10 months ago

0.2.0

10 months ago

0.1.5

10 months ago

0.1.4

10 months ago

0.1.3

10 months ago

0.1.2

10 months ago

0.1.1

10 months ago

0.1.0

10 months ago