diff --git a/.changeset/loud-walls-happen.md b/.changeset/loud-walls-happen.md
new file mode 100644
index 000000000..facdcb079
--- /dev/null
+++ b/.changeset/loud-walls-happen.md
@@ -0,0 +1,5 @@
+---
+"@preact/signals-react": minor
+---
+
+Add a `@preact/signals-react/utils` entrypoint containing common Signal utilities
diff --git a/karma.conf.js b/karma.conf.js
index 1e7c613ed..fd71eaa30 100644
--- a/karma.conf.js
+++ b/karma.conf.js
@@ -224,6 +224,7 @@ const pkgList = {
core: "@preact/signals-core",
preact: "@preact/signals",
react: "@preact/signals-react",
+ "react/utils": "@preact/signals-react/utils",
"react/runtime": "@preact/signals-react/runtime",
"react-transform": "@preact/signals-react-transform",
};
diff --git a/package.json b/package.json
index 579855444..46d3e4143 100644
--- a/package.json
+++ b/package.json
@@ -3,16 +3,18 @@
"private": true,
"scripts": {
"prebuild": "shx rm -rf packages/*/dist/",
- "build": "pnpm build:core && pnpm build:preact && pnpm build:react-runtime && pnpm build:react && pnpm build:react-transform",
+ "build": "pnpm build:core && pnpm build:preact && pnpm build:react-runtime && pnpm build:react && pnpm build:react-transform && pnpm build:react-utils",
"_build": "microbundle --raw --globals @preact/signals-core=preactSignalsCore,preact/hooks=preactHooks,@preact/signals-react/runtime=reactSignalsRuntime",
"build:core": "pnpm _build --cwd packages/core && pnpm postbuild:core",
"build:preact": "pnpm _build --cwd packages/preact && pnpm postbuild:preact",
"build:react": "pnpm _build --cwd packages/react --external \"react,@preact/signals-react/runtime,@preact/signals-core\" && pnpm postbuild:react",
+ "build:react-utils": "pnpm _build --cwd packages/react/utils && pnpm postbuild:react-utils",
"build:react-runtime": "pnpm _build --cwd packages/react/runtime && pnpm postbuild:react-runtime",
"build:react-transform": "pnpm _build --no-compress --cwd packages/react-transform",
"postbuild:core": "cd packages/core/dist && shx mv -f index.d.ts signals-core.d.ts",
"postbuild:preact": "cd packages/preact/dist && shx mv -f preact/src/index.d.ts signals.d.ts && shx rm -rf preact",
"postbuild:react": "cd packages/react/dist && shx mv -f react/src/index.d.ts signals.d.ts && shx rm -rf react",
+ "postbuild:react-utils": "cd packages/react/utils/dist && shx mv -f react/utils/src/index.d.ts . && shx rm -rf react",
"postbuild:react-runtime": "cd packages/react/runtime/dist && shx mv -f react/runtime/src/*.d.ts . && shx rm -rf react",
"lint": "pnpm lint:eslint && pnpm lint:tsc",
"lint:eslint": "eslint 'packages/**/*.{ts,tsx,js,jsx}'",
diff --git a/packages/react/README.md b/packages/react/README.md
index 1ca5edf00..58d396202 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -132,6 +132,84 @@ To opt into this optimization, simply pass the signal directly instead of access
> **Note**
> The content is wrapped in a React Fragment due to React 18's newer, more strict children types.
+## Utility Components and Hooks
+
+The `@preact/signals-react/utils` package provides additional utility components and hooks to make working with signals even easier.
+
+### Show Component
+
+The `Show` component provides a declarative way to conditionally render content based on a signal's value.
+
+```js
+import { Show } from "@preact/signals-react/utils";
+import { signal } from "@preact/signals-react";
+
+const isVisible = signal(false);
+
+function App() {
+ return (
+ Nothing to see here
}>
+ Now you see me!
+
+ );
+}
+
+// You can also use a function to access the value
+function App() {
+ return {value => The value is {value}
};
+}
+```
+
+### For Component
+
+The `For` component helps you render lists from signal arrays with automatic caching of rendered items.
+
+```js
+import { For } from "@preact/signals-react/utils";
+import { signal } from "@preact/signals-react";
+
+const items = signal(["A", "B", "C"]);
+
+function App() {
+ return (
+ No items}>
+ {(item, index) => Item: {item}
}
+
+ );
+}
+```
+
+### Additional Hooks
+
+#### useLiveSignal
+
+The `useLiveSignal` hook allows you to create a local signal that stays synchronized with an external signal.
+
+```js
+import { useLiveSignal } from "@preact/signals-react/utils";
+import { signal } from "@preact/signals-react";
+
+const external = signal(0);
+
+function Component() {
+ const local = useLiveSignal(external);
+ // local will automatically update when external changes
+}
+```
+
+#### useSignalRef
+
+The `useSignalRef` hook creates a signal that behaves like a React ref with a `.current` property.
+
+```js
+import { useSignalRef } from "@preact/signals-react/utils";
+
+function Component() {
+ const ref = useSignalRef(null);
+ return The ref's value is {ref.current}
;
+}
+```
+
## Limitations
This version of React integration does not support passing signals as DOM attributes. Support for this may be added at a later date.
diff --git a/packages/react/package.json b/packages/react/package.json
index 3b80c2dca..2b8d0cd79 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -37,7 +37,14 @@
"import": "./runtime/dist/runtime.mjs",
"require": "./runtime/dist/runtime.js"
},
- "./runtime/package.json": "./runtime/package.json"
+ "./runtime/package.json": "./runtime/package.json",
+ "./utils": {
+ "types": "./utils/dist/index.d.ts",
+ "browser": "./utils/dist/utils.module.js",
+ "import": "./utils/dist/utils.mjs",
+ "require": "./utils/dist/utils.js"
+ },
+ "./utils/package.json": "./utils/package.json"
},
"mangle": "../../mangle.json",
"files": [
diff --git a/packages/react/utils/package.json b/packages/react/utils/package.json
new file mode 100644
index 000000000..94b68c663
--- /dev/null
+++ b/packages/react/utils/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@preact/signals-react-runtime",
+ "description": "Sub package for @preact/signals-react that contains some useful utilities",
+ "private": true,
+ "amdName": "reactSignalsutils",
+ "main": "dist/utils.js",
+ "module": "dist/utils.module.js",
+ "unpkg": "dist/utils.min.js",
+ "types": "dist/index.d.ts",
+ "source": "src/index.ts",
+ "mangle": "../../../mangle.json",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "browser": "./dist/utils.module.js",
+ "import": "./dist/utils.mjs",
+ "require": "./dist/utils.js"
+ }
+ },
+ "dependencies": {
+ "@preact/signals-core": "workspace:^1.3.0"
+ },
+ "peerDependencies": {
+ "react": "^16.14.0 || 17.x || 18.x || 19.x"
+ }
+}
diff --git a/packages/react/utils/src/index.ts b/packages/react/utils/src/index.ts
new file mode 100644
index 000000000..4f31f4e6d
--- /dev/null
+++ b/packages/react/utils/src/index.ts
@@ -0,0 +1,70 @@
+import { ReadonlySignal, Signal } from "@preact/signals-core";
+import { useSignal } from "@preact/signals-react";
+import { useSignals } from "@preact/signals-react/runtime";
+import { Fragment, createElement, useMemo } from "react";
+
+interface ShowProps {
+ when: Signal | ReadonlySignal;
+ fallback?: JSX.Element;
+ children: JSX.Element | ((value: T) => JSX.Element);
+}
+
+export function Show(props: ShowProps): JSX.Element | null {
+ useSignals();
+ const value = props.when.value;
+ if (!value) return props.fallback || null;
+ return typeof props.children === "function"
+ ? props.children(value)
+ : props.children;
+}
+
+interface ForProps {
+ each:
+ | Signal>
+ | ReadonlySignal>
+ | (() => Signal> | ReadonlySignal>);
+ fallback?: JSX.Element;
+ children: (value: T, index: number) => JSX.Element;
+}
+
+export function For(props: ForProps): JSX.Element | null {
+ useSignals();
+ const cache = useMemo(() => new Map(), []);
+ let list = (
+ (typeof props.each === "function" ? props.each() : props.each) as Signal<
+ Array
+ >
+ ).value;
+
+ if (!list.length) return props.fallback || null;
+
+ const items = list.map((value, key) => {
+ if (!cache.has(value)) {
+ cache.set(value, props.children(value, key));
+ }
+ return cache.get(value);
+ });
+ return createElement(Fragment, { children: items });
+}
+
+export function useLiveSignal(value: Signal | ReadonlySignal) {
+ const s = useSignal(value);
+ if (s.peek() !== value) s.value = value;
+ return s;
+}
+
+export function useSignalRef(value: T) {
+ const ref = useSignal(value) as Signal & { current: T };
+ if (!("current" in ref))
+ Object.defineProperty(ref, "current", refSignalProto);
+ return ref;
+}
+const refSignalProto = {
+ configurable: true,
+ get(this: Signal) {
+ return this.value;
+ },
+ set(this: Signal, v: any) {
+ this.value = v;
+ },
+};
diff --git a/packages/react/utils/test/browser/index.test.tsx b/packages/react/utils/test/browser/index.test.tsx
new file mode 100644
index 000000000..4d50d10b1
--- /dev/null
+++ b/packages/react/utils/test/browser/index.test.tsx
@@ -0,0 +1,94 @@
+import { For, Show, useSignalRef } from "../../src";
+import {
+ act,
+ checkHangingAct,
+ createRoot,
+ Root,
+} from "../../../test/shared/utils";
+import { signal } from "@preact/signals-react";
+import { createElement } from "react";
+
+describe("@preact/signals-react-utils", () => {
+ let scratch: HTMLDivElement;
+ let root: Root;
+ async function render(element: Parameters[0]) {
+ await act(() => root.render(element));
+ }
+
+ beforeEach(async () => {
+ scratch = document.createElement("div");
+ document.body.appendChild(scratch);
+ root = await createRoot(scratch);
+ });
+
+ afterEach(async () => {
+ checkHangingAct();
+ await act(() => root.unmount());
+ scratch.remove();
+ });
+
+ describe("", () => {
+ it("Should reactively show an element", async () => {
+ const toggle = signal(false)!;
+ const Paragraph = (p: any) => {p.children}
;
+ await act(() => {
+ render(
+ Hiding}>
+ Showing
+
+ );
+ });
+ expect(scratch.innerHTML).to.eq("Hiding
");
+
+ await act(() => {
+ toggle.value = true;
+ });
+ expect(scratch.innerHTML).to.eq("Showing
");
+ });
+ });
+
+ describe("", () => {
+ it("Should iterate over a list of signals", async () => {
+ const list = signal>([])!;
+ const Paragraph = (p: any) => {p.children}
;
+ await act(() => {
+ render(
+ No items}>
+ {item => {item}}
+
+ );
+ });
+ expect(scratch.innerHTML).to.eq("No items
");
+
+ await act(() => {
+ list.value = ["foo", "bar"];
+ });
+ expect(scratch.innerHTML).to.eq("foo
bar
");
+ });
+ });
+
+ describe("useSignalRef", () => {
+ it("should work", async () => {
+ let ref;
+ const Paragraph = (p: any) => {
+ ref = useSignalRef(null);
+ return p.type === "span" ? (
+ {p.children}
+ ) : (
+ {p.children}
+ );
+ };
+ await act(() => {
+ render(1);
+ });
+ expect(scratch.innerHTML).to.eq("1
");
+ expect((ref as any).value instanceof HTMLParagraphElement).to.eq(true);
+
+ await act(() => {
+ render(1);
+ });
+ expect(scratch.innerHTML).to.eq("1");
+ expect((ref as any).value instanceof HTMLSpanElement).to.eq(true);
+ });
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index b1e354340..2988026e8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,6 +15,7 @@
"@preact/signals-core": ["./packages/core/src/index.ts"],
"@preact/signals": ["./packages/preact/src/index.ts"],
"@preact/signals-react": ["./packages/react/src/index.ts"],
+ "@preact/signals-react/utils": ["./packages/react/utils/src/index.ts"],
"@preact/signals-react/runtime": [
"./packages/react/runtime/src/index.ts"
],