A tiny, fully typed and zero-dependency signals library for React 19, inspired by SolidJS and Apollo. Features include synchronous signals, computed signals with auto-tracking, async signals with Suspense, selector hooks, and batching.
✅ Simple reactive signals (createSignal)
✅ Auto-tracked computed signals (computed)
✅ React hooks (useSignal, useSignalSelector)
✅ [WIP] Async signals with Suspense support (createAsyncSignal, useAsyncSignal)
✅ Global shared signals (like Apollo cache)
✅ Fully typed and tree-shakeable
npm install @chrrrs/signals
# or
yarn add @chrrrs/signals
import { createSignal } from "@chrrrs/signals";
export const count = createSignal(0);
import { useSignal } from "@chrrrs/signals";
import { count } from "./store";
export function Counter() {
const value = useSignal(count);
return (
<div>
<p>{value}</p>
<button onClick={() => count.set(value + 1)}>Increment</button>
</div>
);
}
Auto-tracked dependencies — no manual deps required.
import { computed } from "@chrrrs/signals";
import { count } from "./store";
const double = computed(() => count.get() * 2);
const doubled = useSignal(double);
An async signal is available, work-in-progress on the useAsyncSignal hook and Suspense examples
import { createAsyncSignal } from "@chrrrs/signals";
export const userSignal = createAsyncSignal(async () => {
const res = await fetch("/api/user");
return res.json();
});
<button onClick={() => void userSignal.load()}>Get user</button>
Subscribe to part of a signal to optimize re-renders:
import { useSignalSelector } from "@chrrrs/signals";
import { userSignal } from "./store";
const userName = useSignalSelector(userSignal, user => user?.name ?? "Guest");
- Immutable values: Always .set() new values; do not mutate objects inside a signal.
- Use signals for shared state: Component-local UI state is fine in useState.
- Use async signals for initial fetch: Combine with Suspense, but don’t wrap frequently changing data.
- Batch updates: Use batch() when updating multiple signals together.
- Selector hook: Use useSignalSelector to minimize unnecessary re-renders when only part of the signal matters.
Signals created at module scope are shared across SSR requests.
Avoid:
export const count = createSignal(0);Use:
import { createSignal } from "@chrrrs/signals";
export function createState() {
return {
count: createSignal(0),
};
}| Feature | @chrrrs/signals |
Zustand / Jotai / Valtio |
|---|---|---|
| Size & simplicity | Minimal, easy-to-read API | Larger, more concepts to learn |
| Auto-tracked computed signals | ✅ Recomputes automatically based on dependencies | Zustand: manual derived state Jotai: manual dependencies |
| Async + Suspense integration | ✅ Built-in support via createAsyncSignal |
Jotai: supports async atoms, requires extra boilerplate |
| Batching | ✅ Out-of-the-box | Zustand: requires middleware or manual batching |
| Tree-shakeable | ✅ Fully modular | Varies |
| Learning / debugging | ✅ Small, readable codebase | Larger codebases, more abstractions |
Intended audience: Developers who want small, reactive primitives, not a full-blown state management framework.
useState and context are great for component-local state. If you only need to store a single value, use useState. However, context will cause re-renders of all components that use the context, which can be wasteful, if multiple values or complex state logic is needed, so use signals for shared state instead.
Batching is implicit in React 19. When you update a signal, all components that use that signal will re-render. No need for a batching function.