Skip to content

Signal & Observable [Updated] #34930

@FrameMuse

Description

@FrameMuse

Initial Proposal

There is huge discussion about Signals and Observables, Solid.js is built upon Signals, highly popular library like react-use has useObservable util hook.

"Recently", a Signals proposal appeared and quickly drew attention.

-> Keep reading


Now observables are shipped into browsers officially and I introduce a new update to my earlier proposal.

Use cases

The main use-case of introducing signals to React is keeping Component updates Isolated, which React Team (IMHO) hasn't been able to achieve so far.

We know React Context, which is great actually, but it forces all the children of the context provider to reconcile.

What people trying to achieve with Redux, Jotai Atoms and etc. is essentially the same thing - have a "source" (provider) and "sink" (consumer) independent.

"Independent/Isolated" means

  • The "atom" doesn't need to be declared as Down Tree Component context, you can set/get it any component.
  • You can access the state out of React Component.

Abstract Example (from my previous issue)

import { use } from "react"
import { searchSignal } from "@/signals"

function Images() {
        // Only components that use this signal are updated.
	const search = use(searchSignal)

	return (...)
}

function App() {
        // Parent doesn't need to declare the signal provider.
	return (
		<SearchBar />
		<Images />
	)
}

function SearchBar() {
        // The signal can be updated from anywhere.
	return <input onChange={event => searchSignal.set(event.currentTarget.value)} />
}

This may look as something really simple, but think about a "save" button that sits in the static page header with dynamic body. On click, you want to save changes in the body, but state of your page body and header components are split by really deep components nesting and arrangement - you put React Context, you may say goodbye to your performance and say hello to numerous "unexpected" component effects that may be triggered.

That's where developers came with Redux (essentially an isolated React Reducer) and Jotai Atoms (essentially Signals).

Proposal

Maybe make it a conventional? Here's a 76 lines 100% React-style Signal/Observable implementation that Just Works and looks like official React API. 🫠

import { useEffect, useRef, useSyncExternalStore, type Dispatch, type SetStateAction } from "react"

type Unsubscribe = () => void

interface Signal<T> {
  get: () => T
  set: (value: T) => void
  subscribe: (onChange: () => void) => Unsubscribe
}

interface SignalDispatch {
  dispatch: () => void
  subscribe: (onChange: () => void) => Unsubscribe
}


export function createSignal(): SignalDispatch
export function createSignal<T>(initial: T): Signal<T>
export function createSignal<T>(arg?: T): Signal<T> | SignalDispatch {
  let value = arg
  const subs = new Set<() => void>()

  function get() { return value }

  function set(newValue: T) {
    value = newValue
    dispatch()
  }
  function dispatch() {
    subs.forEach(sub => sub())
  }

  function subscribe(onChange: () => void): Unsubscribe {
    subs.add(onChange)
    return () => subs.delete(onChange)
  }

  if (arg == null) return { dispatch, subscribe }
  return { get, set, subscribe } as never
}

function isSignal<T>(value: any): value is Signal<T> {
  return value instanceof Object && value.get instanceof Function && value.subscribe instanceof Function && value.set instanceof Function
}

/**
 * This is essentially a `useState` hook backed by a Signal.
 *
 * - If passed a plain initial value, it creates and owns a local Signal for the component lifetime.
 * - If passed an existing Signal, it subscribes to that signal and returns its current value + setter.
 *
 * @example
 * const [count, setCount] = useSignal(0) // Local signal.
 * 
 * @example
 * const shared = createSignal(0) // Outside component.
 * const [count, setCount] = useSignal(shared) // Inherit from shared signal.
 */
export function useSignal<T>(signal: Signal<T>): readonly [T, Dispatch<SetStateAction<T>>]
export function useSignal<T>(initialValue: T): readonly [T, Dispatch<SetStateAction<T>>]
export function useSignal<T>(arg: T | Signal<T>): readonly [T, Dispatch<SetStateAction<T>>] {
  // If caller passed an existing signal, use it. Otherwise create a local signal once.
  const signalRef = useRef<Signal<T> | null>(isSignal<T>(arg) ? arg : createSignal(arg as T))

  const signal = signalRef.current as Signal<T>
  const value = useSyncExternalStore(signal.subscribe, signal.get, signal.get)

  function set(action: SetStateAction<T>) {
    signal.set(action instanceof Function ? action(value) : action)
  }

  return [value, set] as const
}
export function useSignalEffect<T>(callback: () => void, signal: Signal<T> | SignalDispatch): void {
  useEffect(() => signal.subscribe(callback), [signal])
}

Explanation

This introduces

  • createSignal - just like createContext, creates a signal instance, which is basically Jotai atom or Redux createStore
  • useSignal - just like useState but shareable. It can be used to useSignal(123) to create signal in place or to get updated from external signal useSignal(otherSignal) - basically Jotai useAtom or Redux useSelector.
  • useSignalEffect - additionally hook to subscribe a callback to updates - useful in cases when the signal is a "Dispatch Signal" (brings no value, just the fact of update).

The React Signal API itself splits into Signal and SignalDispatch, one can pipe values, second one is just to notify.

Usage

In a React application with well defined boundaries, a signal is introduced to connect a "far away" button living in a static header with dynamically update body, where the changes happen.

const someValueSignal = createSignal("")
const anotherValueSignal = createSignal("")
const saveSignal = createSignal()

function DynamicBody() {
  // Component gets updated only when someValueSignal changes, no parent reconciliation.
  const [value, setValue] = useSignal(someValueSignal)

  // Lots of logic...

  return (
    <div>
      <input value={value} onChange={event => setValue(event.target.value)} />
      <input onChange={event => anotherValueSignal.set(event.target.value)} />
      <p>{value}</p>
    </div>
  )
}

function StaticHeader() {
  return (
    <div>
      <button type="button" onClick={() => saveSignal.dispatch()}>Save</button>
    </div>
  )
}

function App() {
  useSignalEffect(() => fetch("api", someValueSignal.get()), [saveSignal])

  return (
    <div>
      <StaticHeader />
      <DynamicBody />
    </div>
  )
}

I used signals for DynamicBody data update as well, but in practice React Context is the choice to ensure that DynamicBody is in the context of related data not Global. But signals are used here to demonstrate that they are useful where we connect components globally, when it's actually reasonable, at the moment we don't care about context, but just want to transfer an event from one "far away" component to another one.

This displays that React Signal API can be a nice addition to React itself and will work well with other APIs too.

Bonus: it can support any Signal-like and Observable-like structures, be just a fancy useSyncExternalStore hook.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions