Skip to content

React Integration

SynState provides a companion package synstate-react-hooks for seamless React integration.

Terminal window
npm add synstate-react-hooks

createState from synstate-react-hooks returns a React hook instead of a raw Observable:

import type * as React from 'react';
import { createState } from 'synstate-react-hooks';
const [useUserState, setUserState] = createState({
name: '',
email: '',
});
const UserProfile = (): React.JSX.Element => {
const user = useUserState();
return (
<div>
<p>{`Name: ${user.name}`}</p>
<button
onClick={() => {
setUserState({
name: 'Alice',
email: 'alice@example.com',
});
}}
>
{'Set User'}
</button>
</div>
);
};

This is equivalent to the following code without synstate-react-hooks:

import * as React from 'react';
import { createState } from 'synstate';
const [userState, setUserState] = createState({
name: '',
email: '',
});
// Defined once: `useSyncExternalStore` subscribes again whenever `subscribe` is
// a new function.
const subscribeUser = (onStoreChange: () => void): (() => void) => {
const { unsubscribe } = userState.subscribe(onStoreChange);
return unsubscribe;
};
const getUser = (): Readonly<{ name: string; email: string }> =>
userState.getSnapshot().value;
const UserProfile = (): React.JSX.Element => {
// `getUser` doubles as `getServerSnapshot`, which server rendering and
// hydration require.
const user = React.useSyncExternalStore(subscribeUser, getUser, getUser);
return (
<div>
<p>{`Name: ${user.name}`}</p>
<button
onClick={() => {
setUserState({
name: 'Alice',
email: 'alice@example.com',
});
}}
>
{'Set User'}
</button>
</div>
);
};

synstate-react-hooks’s createState also returns a 3-element tuple, just like the core package. The third element includes the same utilities (updateState, resetState, getSnapshot, initialState) described in createState in Depth, plus state — the underlying Observable:

import type * as React from 'react';
import { createState } from 'synstate-react-hooks';
// The third element provides additional utilities and the underlying Observable.
const [
useCount,
setCount,
{
state,
updateState: updateCount,
resetState: resetCount,
getSnapshot: getCountSnapshot,
},
] = createState(0);
const increment = (): void => {
updateCount((n) => n + 1);
};
const Counter = (): React.JSX.Element => {
const count = useCount();
return (
<div>
<p>{`Count: ${count}`}</p>
<button onClick={increment}>{'Increment'}</button>
<button onClick={resetCount}>{'Reset'}</button>
</div>
);
};
// `state` is the same InitializedObservable<number> that the core synstate
// package's createState returns as its first element.
// You can use it with pipe, combine, subscribe, etc.
state.subscribe((value) => {
console.log('count changed:', value);
});
// Read the current value synchronously (outside of React rendering)
console.log('current count:', getCountSnapshot());
PropertyTypeDescription
stateInitializedObservable<S>The same Observable that the core synstate package’s createState returns as its first element. Use it with pipe, combine, subscribe, etc.
updateState(updateFn: (prev: S) => S) => SUpdate the state using a function of the previous value (see createState in Depth)
resetState() => SReset the state to its initial value
getSnapshot() => SRead the current value synchronously without subscribing
initialStateSThe initial value passed to createState

Subscribing to Derived Observables: useObservableValue

Section titled “Subscribing to Derived Observables: useObservableValue”

The state property from the third element is a regular Observable that you can transform with pipe, map, combine, etc. To subscribe to a derived Observable in a React component, use useObservableValue:

import type * as React from 'react';
import { map } from 'synstate';
import { createState, useObservableValue } from 'synstate-react-hooks';
const [useCount, , { state: count$ }] = createState(0);
// Derive a new Observable using pipe + map
const doubled$ = count$.pipe(map((n) => n * 2));
const message$ = count$.pipe(
map((n) => (n === 0 ? 'Click to start' : `Count is ${n}`)),
);
const CountDisplay = (): React.JSX.Element => {
const count = useCount(); // Equivalent to using useObservableValue(count$)
// Subscribe to derived Observables with useObservableValue
const doubled = useObservableValue(doubled$);
const message = useObservableValue(message$);
return (
<div>
<p>{`Count: ${count}, Doubled: ${doubled}`}</p>
<p>{message}</p>
</div>
);
};

useObservableValue works with any Observable — whether it comes from createState, pipe, combine, or any other SynState operator. It subscribes to the Observable and returns the current value as React state, re-rendering the component when the value changes.

Fallback for Observables Without an Initial Value

Section titled “Fallback for Observables Without an Initial Value”

useObservableValue is overloaded so the return type tracks whether the source has an initial value:

SignatureReturn type
useObservableValue<A>(obs: InitializedObservable<A>)A
useObservableValue<A>(obs: Observable<A>)A | undefined
useObservableValue<A, B = A>(obs: Observable<A>, initialValue: B)A | B

The second argument is a fallback used while the source has not produced a value yet (it is applied via Optional.unwrapOr):

import type * as React from 'react';
import type { Observable } from 'synstate';
import { useObservableValue } from 'synstate-react-hooks';
// Observable<string> that may not have emitted yet.
declare const userName$: Observable<string>;
const Greeting = (): React.JSX.Element => {
const userName = useObservableValue(userName$, 'Guest'); // string
return <p>{`Hello, ${userName}`}</p>;
};

Observables created from createState (or any pipe chain that preserves the initial value) are InitializedObservable<T> and never need a fallback.

Side Effects While Mounted: useObservableEffect

Section titled “Side Effects While Mounted: useObservableEffect”

useObservableEffect(observable$, fn) subscribes fn when the component mounts and unsubscribes it when the component unmounts, for a side effect that should run only while the component is mounted and that does not change what it renders, such as scrolling back to the top when the query changes. fn is called at once with the current value, if there is one, and then on every emission. The hook returns nothing and does not re-render the component:

import type * as React from 'react';
import { createState, useObservableEffect } from 'synstate-react-hooks';
// Written by the search box
export const [, setQuery, { state: query$ }] = createState('');
// Written by the code that fetches the results
export const [useResults, setResults] = createState<readonly string[]>([]);
const ResultList = (): React.JSX.Element => {
const results = useResults();
// Called on mount with the current query, then on every change, until
// unmount: the page starts at the top and goes back there for a new query
useObservableEffect(query$, () => {
window.scrollTo({ top: 0 });
});
return (
<ul>
{results.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
);
};

Each value goes to the fn of the latest render, so it sees the current props and state, and a new fn on every render does not resubscribe. Passing another Observable moves the subscription to it, so pass one that keeps its identity across renders (made outside the component, or memoized with React.useMemo): one built during render is subscribed again on every render, and fn is then called again with its current value. fn’s return value is ignored; a cleanup on unmount goes in a React.useEffect of your own. fn is a subscriber, so it must not write to a state: that is a write during propagation, which the ESLint rule reports here too. A side effect that does not belong to a component, such as saving a state to localStorage, is a subscribe at module scope. To pass a prop or a component state into the graph, the other way round, use useValueAsObservable(value) (see the API reference).

If you’re using React 16.8–17 (without useSyncExternalStore), install synstate-react-hooks-compat:

Terminal window
npm add synstate-react-hooks-compat

This package provides the same API as synstate-react-hooks but reads observables through use-sync-external-store/shim, React’s own useSyncExternalStore for React 16.8 and 17. Just change the import:

import { createState } from 'synstate-react-hooks-compat';
const [useUserState, setUserState] = createState({
name: '',
email: '',
});

All hooks (createState, createReducer, createBooleanState, useObservableValue, useObservableEffect) work identically. When you upgrade to React 18+, switch the import to synstate-react-hooks — no other code changes are needed.

You can also use the core synstate package directly with useState and useEffect:

import * as React from 'react';
import { createState } from 'synstate';
// Global state (outside component)
const [userState, setUserState, { getSnapshot }] = createState({
name: '',
email: '',
});
const UserProfile = (): React.JSX.Element => {
const [user, setUser] = React.useState(getSnapshot());
React.useEffect(() => {
const subscription = userState.subscribe(setUser);
return () => {
subscription.unsubscribe();
};
}, []);
return (
<div>
<p>{`Name: ${user.name}`}</p>
<button
onClick={() => {
setUserState({
name: 'Alice',
email: 'alice@example.com',
});
}}
>
{'Set User'}
</button>
</div>
);
};