Created
May 30, 2025 02:03
-
-
Save markmals/0000c36b1b880e053d5c1d7ea0ebe006 to your computer and use it in GitHub Desktop.
React hook to subscribe to a Solid signal (or reactive function) and expose its value to React components, with proper reactivity and referential stability for React's rendering model
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { createSignal } from "solid-js"; | |
import { createStore, reconcile } from "solid-js/store"; | |
import { useSignal } from "./use-signal"; | |
const [count, setCount] = createSignal(0); | |
const doubled = () => count() * 2; | |
export function Counter() { | |
const ct = useSignal(count); | |
const db = useSignal(doubled); | |
const increment = () => setCount(c => c + 1); | |
return ( | |
<div> | |
<div> | |
{ct} × 2 = {db} | |
</div> | |
<button onClick={increment}>Increment</button> | |
</div> | |
); | |
} | |
const [counters, setCounters] = createStore([1, 2, 3, 4, 5]); | |
export function Counters() { | |
const prev = useSignal(() => counters); | |
const next = prev[prev.length - 1] ? prev[prev.length - 1] + 1 : 1; | |
const inc = () => setCounters(reconcile([...prev, next])); | |
const dec = () => setCounters(reconcile(prev.slice(0, -1))); | |
return ( | |
<> | |
<h1>Counters</h1> | |
{prev ? prev.map(counter => <Counter key={counter} />) : <i>No Counters</i>} | |
<button onClick={inc}>Add More Counters</button> | |
<button onClick={dec}>Remove Counters</button> | |
</> | |
); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useCallback, useRef, useSyncExternalStore } from "react"; | |
import type { Accessor } from "solid-js"; | |
import { observable } from "solid-js"; | |
import { unwrap } from "solid-js/store"; | |
/** | |
* Creates a shallow clone of the input value. | |
* - For arrays, return a new array with the same elements. | |
* - For objects, return a new object with the same properties. | |
* - For primitives or null, returns the value as-is. | |
*/ | |
function shallowClone<T>(v: T): T { | |
if (typeof v !== "object" || v === null) return v; | |
return Array.isArray(v) ? ([...v] as unknown as T) : ({ ...v } as T); | |
} | |
/** | |
* React hook to subscribe to a Solid signal (or reactive function) and | |
* expose its value to React components, with proper reactivity and | |
* referential stability for React's rendering model. | |
* | |
* @param signal - A Solid `Accessor` (signal or derived computation) | |
* @returns The current value of the signal, updated reactively. | |
*/ | |
export function useSignal<T>(signal: Accessor<T>): T { | |
const version = useRef(0); | |
/** | |
* Subscribes to the reactive function and all of its nested properties (if object/array). | |
*/ | |
const subscribe = useCallback( | |
(invalidate: () => void) => { | |
const $signal = observable(() => { | |
const raw = signal(); | |
if (typeof raw === "object" && raw !== null) { | |
if (Array.isArray(raw)) { | |
// Access each array element to track all indices. | |
raw.forEach((_, i) => raw[i]); | |
} else { | |
// Access each property to track all keys. | |
for (const k in raw) raw[k as keyof typeof raw]; | |
} | |
} | |
return raw; | |
}); | |
let first = true; | |
const { unsubscribe } = $signal.subscribe(() => { | |
// The first call happens immediately on mount, so we skip it. | |
if (first) { | |
first = false; | |
return; | |
} | |
version.current++; | |
invalidate(); | |
}); | |
return unsubscribe; | |
}, | |
[signal], | |
); | |
/** | |
* Returns a stable snapshot of the signal's value. | |
* - If the value is primitive, return it directly. | |
* - If the value is an object/array, return a shallow clone, | |
* cached per version, so React's Object.is equality check works | |
* as expected. | |
*/ | |
type SignalCache = { value: T; version: number } | null; | |
const cache = useRef<SignalCache>(null); | |
const getSnapshot = useCallback((): T => { | |
const raw = signal(); | |
if (typeof raw !== "object" || raw === null) return raw; | |
// If the version hasn't changed, return the cached value. | |
if (cache.current?.version === version.current) { | |
return cache.current.value; | |
} | |
// Otherwise, create a new shallow clone and cache it. | |
const snap = shallowClone(unwrap(raw)); | |
cache.current = { value: snap, version: version.current }; | |
return snap; | |
}, [signal]); | |
return useSyncExternalStore(subscribe, getSnapshot); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment