-
Notifications
You must be signed in to change notification settings - Fork 50k
Description
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.
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 likecreateContext, creates a signal instance, which is basically Jotaiatomor ReduxcreateStoreuseSignal- just likeuseStatebut shareable. It can be used touseSignal(123)to create signal in place or to get updated from external signaluseSignal(otherSignal)- basically JotaiuseAtomor ReduxuseSelector.useSignalEffect- additionally hook to subscribe acallbackto 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.