React Integration
SynState provides a companion package synstate-react-hooks for seamless React integration.
Installation
Section titled “Installation”npm add synstate-react-hooksBasic Usage
Section titled “Basic Usage”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> );};Additional Utilities: Third Element
Section titled “Additional Utilities: Third Element”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());| Property | Type | Description |
|---|---|---|
state | InitializedObservable<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) => S | Update the state using a function of the previous value (see createState in Depth) |
resetState | () => S | Reset the state to its initial value |
getSnapshot | () => S | Read the current value synchronously without subscribing |
initialState | S | The 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 + mapconst 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:
| Signature | Return 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 boxexport const [, setQuery, { state: query$ }] = createState('');
// Written by the code that fetches the resultsexport 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).
React v17 or Earlier
Section titled “React v17 or Earlier”If you’re using React 16.8–17 (without useSyncExternalStore), install synstate-react-hooks-compat:
npm add synstate-react-hooks-compatThis 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.
Manual alternative (no extra package)
Section titled “Manual alternative (no extra package)”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> );};