Core concepts
React Integration
Signalium provides first-class integration with React through the signalium/react subpackage. This integration allows you to use Signals directly in your React components while maintaining React's component model and lifecycle.
A Basic Component
Signalium provides a component helper, which allows you to define a reactive component, and the useSignal hook, which allows you to define Signals inside your component.
import { component, useSignal } from 'signalium/react';
const Counter = component(() => {
const count = useSignal(0);
return (
<div>
<p>Count: {count.value}</p>
<button onClick={() => count.value++}>Increment</button>
</div>
);
});
Components are memoized using the same rules as Reactive Functions, so they will only re-render when either:
- The component's props differ semi-deeply from the previous props (e.g. objects, arrays, and primitive values are deeply compared, but any kind of class instance that is not a plain object is compared via reference).
- The Signals that the component uses have been updated.
And any Reactive Functions that the component uses will rerun prior to the component being rendered.
Using Reactive Functions in components
Reactive Functions can be created and used in components just like normal functions, and they will update when the Signals they depend on update.
import { reactive } from 'signalium';
const doubled = reactive((count) => count.value * 2);
const Counter = component(() => {
const count = useSignal(0);
const doubled = reactive(() => count.value * 2);
return (
<div>
<p>Count: {count.value}</p>
<p>Doubled: {doubled()}</p>
<button onClick={() => count.value++}>Increment</button>
</div>
);
});
You can also extract Reactive Functions out of components to make reusable functions, much like you would with custom hooks.
import { reactive } from 'signalium';
const doubled = reactive((count: number) => count.value * 2);
const Counter = component(() => {
const count = useSignal(0);
return (
<div>
<p>Doubled: {doubled(count)}</p>
<button onClick={() => count.value++}>Increment</button>
</div>
);
});
Rules-of-Hooks vs Rules-of-Signals
Signalium Components are fully compatible with React's own Hooks system. You can mix and match them as you please.
import { useState, useMemo } from 'react';
import { component, useSignal } from 'signalium/react';
const Counter = component(() => {
const [multiplier, setMultiplier] = useState(2);
const count = useSignal(0);
const multiplied = reactive(() => count.value * multiplier);
return (
<div>
<p>Result: {multiplied()}</p>
<button onClick={() => count.value++}>Increment</button>
<button onClick={() => setMultiplier(multiplier + 1)}>
Increment Multiplier
</button>
</div>
);
});
However, when using hooks inside of a component, including useSignal, you do still need to follow the Rules-of-Hooks. In addition, you cannot use hooks inside of Reactive Functions.
import { useState, useMemo } from 'react';
import { component, useSignal } from 'signalium/react';
const Counter = component(() => {
const [multiplier, setMultiplier] = useState(2);
const multiplied = reactive(() => {
// 🛑 This is invalid! You cannot use hooks inside of Reactive Functions.
const [count] = useState(0);
return count * 2;
});
return (
<div>
<p>Result: {multiplied()}</p>
<button onClick={() => setMultiplier(multiplier + 1)}>
Increment Multiplier
</button>
</div>
);
});
One of the major benefits of using Signals over hooks is that you to don't need to follow the Rules-of-Hooks when using them directly inside of components. You can access Signals and Reactive Functions conditionally, in any order, and they will still work as expected.
import { reactive } from 'signalium';
import { signal, type Signal } from 'signalium';
const direction = signal<'up' | 'down'>('up');
const doubled = reactive((count: Signal<number>) => count.value * 2);
const tripled = reactive((count: Signal<number>) => count.value * 3);
const Doubled = component(() => {
const count = useSignal(0);
const result = direction.value === 'up' ? doubled(count) : tripled(count);
return (
<div>
<p>Result: {result}</p>
<button onClick={() => count.value++}>Increment</button>
</div>
);
});
useSignal is the one exception here, because it is essentially a wrapper around useState and integrates with React's state management system to provide persistence across renders. Just remember, if it's named like a Hook, it's still a Hook, and still must follow the Rules-of-Hooks.
State ownership
One key difference between standard React hooks and Signalium's Reactive Functions is that you cannot create state Signals inside of Reactive Functions.
import { reactive } from 'signalium';
const doubled = reactive((count: number) => {
// 🛑 This is invalid! You cannot create
// Signals inside of Reactive Functions.
const count = useSignal(0);
count.value * 2;
});
const Counter = component(() => {
return (
<div>
<p>Doubled: {doubled(count)}</p>
<button onClick={() => count.value++}>Increment</button>
</div>
);
});
The reason for this is that introducing Signals to Reactive Functions would break the signal-purity guarantee, as we discussed previously.
But, you can create Signals outside of Reactive Functions, and then use them inside of Reactive Functions, just like our example above. And one of the major benefits of passing Signals around by reference is that it allows you to avoid excessive re-renders.
import { reactive } from 'signalium';
const add = reactive(
(a: Signal<number>, b: Signal<number>) => a.value + b.value,
);
const Sum = component(() => {
const a = useSignal(1);
const b = useSignal(2);
return (
<div>
<p>Sum: {add(a, b)}</p>
<button
onClick={() => {
a.value = 3;
b.value = 0;
}}
>
Change
</button>
</div>
);
});
In this example, because we passed Signals as parameters, the Reactive Function will rerun whenever those Signals update. However, since the result of the function is the same, the component itself does not need to re-render.
Signals allow us to pass around state by reference, down through multiple levels of components and Reactive Functions, and then only re-render the components that were actually affected by a change. This means you no longer need to use contexts just to avoid excessive re-renders due to prop changes. The Signal itself is stable; it's just the value that changes.
This is why, as mentioned before, Signalium takes the opinionated stance that if a function's output would be different in two different components, then the component should define the state and pass it to the function as a parameter. This defines clear state ownership and prevents functions from adding implicit statefulness, maintaining signal-purity.
Async Data and Promises
Signalium's Reactive Promises work seamlessly with React components. You can easily handle loading and error states with standard using the isPending, isRejected, and isReady properties.
const DataComponent = component(() => {
const data = useReactive(getData); // returns a Reactive Promise
if (data.isPending) {
return <div>Loading...</div>;
}
if (data.isRejected) {
return <div>Error: {String(data.error)}</div>;
}
return <div>{data.value}</div>;
});
However, there are a few important things to note:
Sync vs async body. A plain
component(() => …)cannot be anasyncfunction in source. You either handleReactivePromisestate explicitly (isPending,isReady, …) as above, or you usecomponent(async () => { await … })with the Signalium Babel preset (async transform), which compilesawaitinto Suspense-friendly semantics—see Async components with Suspense. Async components must be wrapped in<Suspense>.Reactive Promises are always the same object instance, even when their value changes. This means that
React.memowill not trigger a re-render when the promise's value updates:
import { memo } from 'react';
// This component will not re-render when the promise value changes
const MemoizedComponent = memo(({ promise }) => {
return <div>{promise.value}</div>;
});
// Instead, use the value directly
const MemoizedComponent = memo(({ value }) => {
return <div>{value}</div>;
});
function Parent() {
const data = useReactive(getData); // returns a Reactive Promise
return <MemoizedComponent value={data.value} />;
}
Async components with Suspense
With the async transform, you can author component(async () => { … }) using normal async/await. The preset rewrites them for Signalium’s React integration; await a ReactivePromise (for example by calling an async reactive such as reactive(async () => { … })) and wrap the tree in <Suspense>.
import { Suspense } from 'react';
import { component } from 'signalium/react';
import { reactive } from 'signalium';
const loadUser = reactive(async (id: string) => {
const res = await fetch(`/api/user/${id}`);
return res.json();
});
export const Profile = component(async (props: { id: string }) => {
const user = await loadUser(props.id);
return <div>{user.name}</div>;
});
// Usage
<Suspense fallback={<div>Loading profile…</div>}>
<Profile id={userId} />
</Suspense>;
Updates: eager for React state, lazy for async reactives
The mental model is close to React Transitions and Suspense:
Eager replays — When React schedules an update from local state (
useState,useReducer, …) or props, the component body is run again from the top on the usual React timeline. Everything before the next pendingawaitexecutes synchronously on that attempt, so high-priority UI (counters, form fields, chrome around a loading region) can stay responsive.Lazy / deferred continuation — When execution hits an
awaitwhose value is a pending reactive async result (aReactivePromisethat is not ready yet), that point behaves likeuse(promise): the render is interrupted, Suspense can show a fallback, and code after thatawait(including hooks placed after it) runs only after the async work settles and React retries—again from the top.
So: ordinary React updates rerun the component eagerly up to the next async boundary, while async reactives control how far each pass gets before handing off to Suspense. Durable values still belong in React state, refs, signals, or module-level reactives—not in let bindings between awaits, which replay fresh each attempt.
Suspense and React Server Components
Because Reactive Promises implement the Promise interface, they can be used with use, Suspense, and React Server Components in general. In fact, the exact same Reactive Functions can be used on both the server and the client:
// app/page.tsx
import { Suspense } from 'react';
import { ServerDataComponent } from './ui/server-data-component';
import { ClientDataComponent } from './ui/client-data-component';
export function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ServerDataComponent />
<ClientDataComponent />
</Suspense>
);
}
// app/ui/server-data-component.tsx
import { getData } from '../lib/query';
export async function ServerDataComponent() {
const data = await getData();
return <div>{data.value}</div>;
}
// app/ui/client-data-component.tsx
import { use } from 'react';
import { component } from 'signalium/react';
import { getData } from '../lib/query';
export const ClientDataComponent = component(() => {
const data = use(getData());
return <div>{data}</div>;
}
// app/lib/query.ts
import { reactive } from 'signalium';
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const getData = reactive(async () => {
await sleep(1000);
return 'Hello, world';
});
Because getData is an async Reactive Function, it will return a Reactive Promise which can be used with use to await the result on the client, or await on the server.
There are some caveats with using Signalium in RSCs at the moment:
- While imports from
signaliumare fully supported on the server, thesignalium/reactsubpackage is not currently as all of the helpers were designed specifically for clients. The plan moving forward is to implement alternative helpers for the server and usemodule.exportsto specify which package to use in which environment, which is why we haven't just addeduse client;to the top of the files. - Reactive Functions deduplicate results by default, which means that they will share state across requests currently. This is dangerous behavior in general as you can leak state between requests, so for the moment they should only be used for values that are static across all requests. The plan here is to implement a mechanism based on
React.cacheto deduplicate results across requests allow Signalium contexts to be provided for each request.
Contexts
Signalium's Context system integrates with React's Context system through the ContextProvider component:
import { ContextProvider } from '@signalium/react';
import { context, state } from 'signalium';
const ThemeContext = context(signal('light'));
function App() {
return (
<ContextProvider contexts={[[ThemeContext, signal('dark')]]}>
<YourApp />
</ContextProvider>
);
}
function ThemedComponent() {
const theme = useContext(ThemeContext);
return <div>Current theme: {theme.value}</div>;
}
Multiple contexts can be provided to the ContextProvider component, removing the need to nest many context providers in your component tree:
<ContextProvider
contexts={[
[ThemeContext, signal('dark')],
[OtherContext, signal('foo')],
]}
>
<YourApp />
</ContextProvider>
The primary reason for this is for performance. Each time we add a new provider, we create a new scope and rerun all of the Reactive Functions in that scope. This is why it's generally better to flatten multiple Contexts into a single provider, rather than nesting them.
Pausing Signals
In certain scenarios, particularly in React Native applications, you may need to temporarily pause signal updates without fully unmounting components. For example, React Native keeps tab screens mounted in the background, but you don't want those background screens consuming resources or receiving updates.
Signalium provides PauseSignalsProvider to handle this use case:
import { PauseSignalsProvider } from 'signalium/react';
function TabNavigator() {
const [activeTab, setActiveTab] = useState('home');
return (
<>
<PauseSignalsProvider value={activeTab !== 'home'}>
<HomeScreen />
</PauseSignalsProvider>
<PauseSignalsProvider value={activeTab !== 'profile'}>
<ProfileScreen />
</PauseSignalsProvider>
</>
);
}
How Pausing Works
When a signal subtree is paused (PauseSignalsProvider value={true}):
- Unwatched: Signals are unwatched, relays are torn down
- No re-renders: Signal updates don't trigger component re-renders
- Value preservation: Signals maintain their last known value in the component
- No descendant re-renders: Toggling the provider does not re-render descendants (the context value is a stable reference)
When the signal subtree is resumed (value={false}):
- Re-watched: Signals are re-watched, relays re-bootstrap
- Immediate sync: Components immediately show the current signal values
- Normal operation: Signal updates resume triggering re-renders
Note
Paused signals will still rerun if accessed manually. This means that if the component tree rerenders for any other reason, the signals will rerun as well. However, none of the relays within the paused subtree will be reactivated, so it will recompute with the last known values for all relays.